textmachine/backend/internal/pipeline/runner.go

579 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package pipeline
import (
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"unicode"
"textmachine/backend/internal/checks"
"textmachine/backend/internal/chunk"
"textmachine/backend/internal/config"
"textmachine/backend/internal/lang"
"textmachine/backend/internal/ledger"
"textmachine/backend/internal/llm"
"textmachine/backend/internal/membank"
"textmachine/backend/internal/store"
"textmachine/backend/internal/terminology"
)
// runner.go: runner setup — Runner and its wiring (config stack, store in the right
// ownership mode, client/template caches). The book machinery itself is split across
// related files (package №4): snapshot — snapshot.go, seed/ruby — seeding.go, the book
// loop — bookrun.go, the chunk disposition loop — chunkrun.go, stage execution
// with money (reserve → call → settle+checkpoint) — stagerun.go, single-hop
// escalation with re-gating — escalation.go, $0-resume of a resolved one — resume.go.
// roleTranslator is the stage role whose output is a direct source→target
// translation — the only role the excision coverage-gate is meaningful on.
const roleTranslator = "translator"
// roleEditor is the BILINGUAL editor stage (D30.1 supersede of D1): editor.md feeds it BOTH the
// source and the draft, plus the approved glossary's CONFIRMED dst forms as target-consistency
// constraints. (The stale "monolingual, never sees the source" framing is D1, fully superseded.)
const roleEditor = "editor"
// Runner executes a book's pipeline.
type Runner struct {
Book *config.Book
Models *config.Models
Pipeline *config.Pipeline
Store *store.Store
Pricer *ledger.Pricer
Log *slog.Logger
// Resnapshot re-pins existing jobs to the CURRENT snapshot when configs/
// prompts changed since the job started (tmctl --resnapshot). Without the flag
// a divergence is a loud error: a context change invalidates the checkpoints and
// re-pays for the calls, so it is done only deliberately (Р6).
Resnapshot bool
// AcceptRebill is the operator's consent to the projected RE-PAYMENT of already-billed work
// (tmctl --accept-rebill[=usd], D20.2-Q2). It is ORTHOGONAL to Resnapshot: that flag grants
// permission to re-pin, this one consents to an AMOUNT (rebill.go). Zero value = no consent, which
// only matters when the projection exceeds the book's threshold.
AcceptRebill RebillConsent
// VerifyBank is the operator's request to STOP at the bank-mining boundary and review the bank before
// the edit wave (tmctl --verify-bank, D39.42 п.5). Default OFF: the run consolidates what it mined,
// carries it forward marked-unverified, and finishes — «просто как намайнит и закончит». It is an
// operator MODE, not a wire or snapshot axis: it changes when a human is asked, never what is sent.
VerifyBank bool
// CeilingUSD is the BOOK ceiling for THIS RUN ONLY (tmctl --ceiling-usd, backlog row 145 / D39.110),
// 0 = unset. It OVERRIDES book.yaml's ceilings.book_usd for the duration of the process and is never
// written back: the caller's number stays a property of the run, so a platform can cap a run without
// editing the engine's book config (the zone mixing D39.81/D39.85 forbid).
//
// ⚠ It is a BOOK ceiling, not a run BUDGET: the ledger compares it against the book's cumulative
// committed+reserved (store/ledger.go), so a caller granting "another $2" passes already-spent + $2,
// and a value below what the book already spent denies the first reservation of the run. That is the
// same quantity book.yaml's ceilings.book_usd is, which is what makes overriding it coherent — the
// alternative (a second, per-process budget axis) would be a new money mechanism, not this row. Like VerifyBank it is an
// operator axis, not a wire one — the ceilings are in NEITHER BriefHash (config/book.go: "Wiring
// fields (paths, ceilings, db) deliberately excluded") NOR the snapshot payload (snapshot.go), so
// setting it moves no request_hash and re-bills nothing. The DAY ceiling is deliberately not
// overridable: it is an account-wide guard, not a property of one run.
CeilingUSD float64
// MaxUnits is the VOLUME ceiling for THIS RUN (tmctl --max-units, D39.165 §1б), 0 = unset. It bounds
// how many OUTPUT UNITS this run may pay for — the granularity the manifest publishes and the platform
// sells chapters in — so a purchase of N chapters can stop at N instead of at whatever N chapters'
// worth of dollars happens to buy.
//
// ⚠ IT IS ORTHOGONAL TO CeilingUSD AND MUST STAY SO. CeilingUSD is a BOOK-CUMULATIVE money bound the
// ledger judges every reservation against; MaxUnits is a bound on THIS run's own work and never
// touches the ledger, the reservations or the hold arithmetic the platform builds on that
// cumulativeness. They can both be in force and either can be the one that stops the run — and the two
// stops are different answers to the operator: money means "the run was cut short, add money", volume
// means "the run did what was bought". Like the ceilings it is an operator axis, not a wire one: it is
// in neither BriefHash nor the snapshot payload, so setting it moves no request_hash and re-bills nobody.
MaxUnits int
clients map[string]llm.LLMClient
templates map[string]*PromptTemplate
// repairTemplates holds one prompt per ENABLED repair class (pack-16), keyed by class. Empty when the
// repair gate is off, which is the default; its SHAs are folded into the snapshot of the wave that owns
// the shipping stage, because a repair prompt is not a stage and the per-stage fold cannot see it.
repairTemplates map[checks.RepairClass]*PromptTemplate
// terminologyTemplate is the pair's TERMINOLOGIST role prompt (pack-20), loaded only when the gate is on.
// nil otherwise, which is what makes the whole role a no-op for every book that does not enable it. It is
// NOT snapshot-folded: the role writes a signature map, not a checkpoint (config.TerminologyGate).
terminologyTemplate *PromptTemplate
// classifierTemplate is the pair's §2 type-classifier prompt, loaded only when classify_types is on. nil
// otherwise → the classifier phase is a no-op and the draft heuristic type stands.
classifierTemplate *PromptTemplate
// targetScript is the target language's Unicode script, resolved once from gates.terminology
// .target_script (config validates the name). nil when the book declares none — the answer-language
// screen is then inert, which loadTargetScript says out loud if any channel could have used it.
targetScript *unicode.RangeTable
// familyParams is the §G1 family channel resolved from the source's declared morphology, once, at load
// time (loadFamilyParams). The zero value is a disabled channel — the batcher then co-batches series
// only, exactly as before the channel existed. Like the rest of the terminology axis it is NOT
// snapshot-folded: it changes which candidates share a CALL, never a wave byte.
familyParams terminology.FamilyParams
// rateGuards is the per-model wave-concurrency guard set (WS1 §1б), built once in the precompute pass and
// read-only in the waves — a transport axis, never snapshot-folded. nil until buildRateGuards.
rateGuards map[string]*rateGuard
// evictedRows accumulates WHICH bank rows the injection budget dropped (src → how many units), so the
// wave can NAME them once rather than leave n_evicted as an unactionable count. Written from N draft
// workers, hence the mutex; drained by reportEvicted.
evictMu sync.Mutex
evictedRows map[string]int
// bankSrc is the banknote parser's "is this surface in the book?" index (backlog 19), built once from
// the chunk manifest in the precompute pass. nil outside a book run → the parser then judges a line by
// the chunk text it was handed, and accepts nothing without one.
bankSrc *bankSourceIndex
// memory is the book's glossary FROZEN for this job (materialized once in
// TranslateBook, after seeding, before snapshotID). Its Version() is the F1
// content-hash folded into the snapshot; its Select drives per-chunk injection.
// nil until materialized (report path / no glossary) → memoryVersion() falls back
// to the empty-materialization hash, a stable constant.
memory *membank.Bank
// baseMemory is the DRAFT-wave glossary bank: the BASE rows only (Source∈{seed,ruby,auto}, EXCLUDING
// Source:mined). The draft wave's injection MUST be selected over this — not the enriched `memory` —
// so it is BYTE-IDENTICAL across a bank-mining enrichment, matching the draft-wave snapshot which folds
// baseMemoryVersion (mined-excluded). Otherwise signing a mined term would change the draft wire (the
// injection is a message folded into request_hash) and silently re-bill the draft wave on the owner
// re-run — even though the base snapshot is unchanged (the review-confirmed "ONE re-payment" hole).
// When the book has NO mined rows (every $0 test / the golden), it is the SAME object as `memory`
// (identical content) — no double materialization, the injection is unchanged. The editor keeps the
// enriched `memory`. nil ⇔ memory is nil (materialized together in seedGlossary).
baseMemory *membank.Bank
// contentHashes memoizes the per-position wire-hash reproduction for the life of the CURRENT
// materialized bank (cachedRenderedContentHashes). Cleared by materializeBanks, which is the one place
// the bank can change.
contentHashes map[chunkKey]map[string]string
// bankRows are the glossary rows the CURRENT materialization was built from, in the store's order
// (materializeBanks). They exist so a read surface can describe the bank it actually folded rather
// than re-reading a different one out of the store.
bankRows []store.GlossaryEntry
// checkers is the compiled WS5/pack-13 observability checker spec (pair-14 data-out): the pair's
// DETECTION patterns + tables (from pack) + the target-general lists (from embedded target data),
// compiled once in loadLangPack. nil-safe: a no-pack / no-target-data book runs the checkers inert.
checkers *checks.Checkers
// pack is the book's language-data pack (internal/lang), loaded once in openRunner from
// book.LangpackRoot (D39.15/16). The the bank-mining stop bank-miner reads its tables; pack.Version() is folded into
// the snapshot (a pack edit is a loud --resnapshot). nil when the book declares no langpack_root, or
// its pair has no catalog dir — then the miner is inert (the bank-mining stop auto-continues) and the fold is omitted.
pack *lang.Pack
// escMu serializes the single-hop escalation budget admission across the PARALLEL draft workers
// (R1). escalationBudgetRemains is a non-atomic read-then-act over EscalationSpentUSD, so N concurrent
// draft chunks could each read spent<budget and all be admitted → the premium soft-cap overshoots by
// up to N-1 hops. Holding escMu across the budget check AND the fresh hop's settle makes the cap exact
// (a later chunk reads the updated spend). Escalations are the rare content-failure exception, so the
// contention is negligible; the $0 checkpoint-replay of an already-paid hop stays lock-free.
escMu sync.Mutex
// lastMinedCount is the size of the most recent the bank-mining stop mined delta (set by runBankMiningStop) — carried into the
// WaveSignatureStop the driver returns so the CLI can report "N terms await signature". Single-writer
// (runBankMiningStop runs between the parallel waves, not inside one), so no synchronization is needed.
lastMinedCount int
// lastTerminology / lastBankStopRows are the bank-mining stop's RICH output (pack-20): the
// terminologist's outcome counters and the projected rows the operator-facing stop renders — the table
// D39.36 promised (src · dst · frequency · spread · evidence) instead of a term count. Set by the same
// single writer as lastMinedCount. The merged candidate slice itself is deliberately NOT held: it is
// consumed where it is built, and a book-sized slice kept alive for nobody is a leak with a comment.
lastTerminology terminologyResult
lastBankStopRows []BankStopRow
// repinCache memoizes the $0 re-pin predicate (repin.go) per (stored snapshot, current snapshot). The
// draft wave calls it from N parallel workers over the SAME pair of snapshots, so without the cache the
// store would be read once per chunk for an answer that cannot differ.
repinMu sync.Mutex
repinCache map[string]bool
// events is the run-event seam (row 103): the journal the platform tails. It exists only on the WRITE
// path — a read-only projection must not append to a stream that describes runs — and a nil one is a
// no-op at every call site, which is what keeps every $0 test and the golden byte-identical.
events *emitter
}
// NewRunner loads all three configs rooted at book.yaml and opens the project
// store. Caller owns Close.
func NewRunner(bookPath string, logger *slog.Logger) (*Runner, error) {
return openRunner(bookPath, logger, true)
}
// NewReadOnlyRunner builds a runner for the $0 read-only commands (`tmctl report`/`status`): it
// SKIPS the API-key preflight so an auditor WITHOUT provider keys can read the store (D20.4). Those
// commands make zero LLM calls (they only project the persisted rows / re-chunk the source), so a
// missing key is irrelevant. The config-mechanics lint (CheckRunnable) still runs on this path, and so
// does content-routing RESOLUTION — the read models re-render the snapshot, so they must see the same
// resolved models translate did — but the routing REFUSALS are write-only (openRunner). translate and
// redrive (which DO call providers) keep NewRunner.
//
// The store opens READ-ONLY and WITHOUT the exclusive flock (store.OpenReadOnly), so
// status/report work DURING a live translate — previously the operator was locked out of them for the
// entire run (the pain of the package №4 smoke-run). The first touch of a project (no DB file
// yet) falls back to a full Open: create+migrate an empty database — the prior
// behavior "status before the first run shows 0/N pending", and a live writer
// to collide with does not exist at that moment.
func NewReadOnlyRunner(bookPath string, logger *slog.Logger) (*Runner, error) {
return openRunner(bookPath, logger, false)
}
// openRunner loads the config stack and opens the store. forWrite selects the call
// path: translate/redrive (provider keys required, store owned —
// flock+migrations+recovery) versus status/report (no keys, store read-only).
// Every failure BEFORE the store opens is a REFUSAL, not a failure: nothing reached a provider, nothing
// was spent, no work needs rolling back and a retry is safe (the band floor, refusal.go) — and on THIS
// path, as a clause of the classes it raises, nothing of the book's state changed either. They are
// classified rather than collapsed onto exit 1 because an
// automated caller acts on the difference — the platform's intake retries a book five times and then
// rejects it as `source_unreadable`, and it came within one step of deleting a user's upload over an
// operator's typo in a hand-written book.yaml (PD-196). "The text is unusable" and "the config is
// broken" have opposite repairs and opposite consequences for the file.
func openRunner(bookPath string, logger *slog.Logger, forWrite bool) (*Runner, error) {
book, err := config.LoadBook(bookPath)
if err != nil {
return nil, RefuseConfig(err)
}
models, err := config.LoadModels(book.ModelsFile)
if err != nil {
return nil, refuse(RefusalBadConfig, err)
}
pipe, err := config.LoadPipeline(book.Pipeline, models, book.LangPair(), book.ContentLabels)
if err != nil {
return nil, refuse(RefusalBadConfig, err)
}
// Fail-fast BEFORE opening the store (taking the flock and side-effects): whether
// the config mechanics are executable and the keys of the used models are set.
if err := pipe.CheckRunnable(); err != nil {
return nil, refuse(RefusalBadConfig, err)
}
// Content-routing REFUSALS are for the paths that spend money or reach a provider; the routing
// itself was RESOLVED for every path inside LoadPipeline (the read models re-render the snapshot, so
// they must see the same resolved models translate did). A book labelled AFTER it was paid for has to
// stay inspectable — $0 status/report/export keep working and only warn (D20.4/D39.26 point 9),
// because the alternative teaches the operator to strip the label, which is the silent bypass the
// whole mechanism exists to prevent.
// Key preflight is likewise write-only. CheckRunnable above stays unconditional (it costs nothing and
// catches a broken config even on a read).
if forWrite {
if err := pipe.ContentRoutingError(); err != nil {
return nil, refuse(RefusalBadConfig, err)
}
if err := models.CheckKeys(pipe); err != nil {
return nil, refuse(RefusalBadConfig, err)
}
} else if err := pipe.ContentRoutingError(); err != nil {
logger.Warn("content routing is not runnable for this book; read-only projections continue (translate/redrive would refuse)", "err", err)
}
pricer, err := models.Prices()
if err != nil {
return nil, refuse(RefusalBadConfig, err)
}
var st *store.Store
if forWrite {
// Write path (translate/redrive): exclusive owner — flock, migrations,
// reservation recovery.
st, err = store.Open(book.ProjectDB)
} else {
// Read path (status/report): no flock — works during a live run; on
// the first touch of a project (no DB yet) — a full Open (create+migrate).
st, err = store.OpenReadOnly(book.ProjectDB)
if err != nil && errors.Is(err, os.ErrNotExist) {
st, err = store.Open(book.ProjectDB)
}
}
if err != nil {
return nil, RefuseStoreOpen(err)
}
r := &Runner{
Book: book,
Models: models,
Pipeline: pipe,
Store: st,
Pricer: pricer,
Log: logger,
clients: map[string]llm.LLMClient{},
templates: map[string]*PromptTemplate{},
}
if err := r.loadTemplates(); err != nil {
st.Close()
return nil, err
}
if err := r.loadLangPack(); err != nil {
st.Close()
return nil, err
}
// The repair prompts are resolved by the same convention discipline as the stage prompts: a missing file
// for an enabled class is a LOUD stop naming the path, never a silent skip of the loop.
if err := r.loadRepairTemplates(); err != nil {
st.Close()
return nil, err
}
// Same discipline for the terminologist role prompt (pack-20): an enabled gate whose prompt is missing
// or malformed stops the load, so the failure lands before the draft wave rather than after it is paid for.
if err := r.loadTerminologyTemplate(); err != nil {
st.Close()
return nil, err
}
r.loadTargetScript()
// The echo-exposure half of the reasoning-off hole (D39.26 добор B): loud, not fatal — see
// sourceEchoExposure. Needs the templates, so it runs after they load.
if exposed := r.sourceEchoExposure(); len(exposed) > 0 {
logger.Warn("a stage sends the SOURCE text to a model whose wire SUPPRESSES thinking — the ratified echo zone (D19.1 point 2: reasoning-off + dense CJK is a property of the model class, not a vendor quirk). Not a stop: the downstream echo gate (cjk_artifact) still catches an echo on every configuration (D19.2), but it costs a paid call per hit",
"stages", strings.Join(exposed, ", "), "source_lang", r.Book.SourceLang)
}
return r, nil
}
// loadLangPack resolves the book's language-data pack (D39.15/16), loaded on BOTH the write path and the
// read-only path so status/export/report reproduce the identical snapshot (pack.Version() is folded). The
// contract mirrors the owner directive R1: a pair WITH a catalog directory (configs/langpacks/<src>-<tgt>/)
// loads FAIL-LOUD (a missing/corrupt file stops the run, never a silently-empty miner); a pair WITHOUT a
// catalog — or a book with no langpack_root — runs with a nil pack (the miner is inert, the bank-mining stop auto-continues,
// and the snapshot fold is omitted). Presence of the pair directory is the "catalog exists" signal.
func (r *Runner) loadLangPack() error {
// The observability checkers compile from the pair pack (DETECTION patterns + tables, may be nil) AND the
// embedded target data (target-general lists, keyed by target lang). Built here so it exists even for a
// no-langpack book (a nil pack → inert pair checkers; the target lists still load). r.pack is set below.
defer func() {
r.checkers = checks.CompileCheckersFor(r.pack, lang.TargetChecksFor(r.Book.TargetLang))
// The SOURCE writing script(s) are a runner fact (the book's source lang), not target/pair data — set
// after compile so the echo detector and the repair script-guard share one declared-source notion.
r.checkers.SetSourceScripts(lang.LangScripts(r.Book.SourceLang))
}()
if r.Book.LangpackRoot == "" {
return nil // no langpack declared → nil pack, miner inert
}
pairDir := filepath.Join(r.Book.LangpackRoot, r.Book.LangPair())
if fi, err := os.Stat(pairDir); err != nil || !fi.IsDir() {
// No catalog for this pair → nil-and-run (a ja book against a zh-only root just runs without mining).
return nil
}
// A book-scoped overlay (LangpackExtend) unions the book's PRIVATE canon onto the shared pair pack
// (pair-14 §1: 古月 is a 蛊真人 clan, not a shared 百家姓 surname). "" ⇒ plain Load. Folds into Version().
pack, err := lang.LoadWithOverlay(r.Book.LangpackRoot, r.Book.SourceLang, r.Book.TargetLang, r.Book.LangpackExtend)
if err != nil {
return fmt.Errorf("pipeline: load langpack for %s: %w", r.Book.LangPair(), err)
}
r.pack = pack
// A pack that loaded but declares no name-morphology channel (its manifest opts out) runs the miner on an
// INERT channel — empty tables, zero candidates. Say so out loud rather than mine silently (P4 contract).
if !pack.HasMorphologyChannel() {
r.Log.Warn("langpack declares no source-morphology channel — the name-miner is inert (no candidates); this is expected for a source without the pinyin name-miner schema",
"pair", r.Book.LangPair())
}
return nil
}
// packVersion is the snapshot-folded language-pack version: pack.Version() when a pack is loaded, "" when
// not (omitted from the snapshot so a no-pack book is byte-stable and never re-billed for a feature it does
// not use). A pack DATA edit changes Version() → the snapshot moves → a loud --resnapshot (R1, drift-proof).
func (r *Runner) packVersion() string {
if r.pack != nil {
return r.pack.Version()
}
return ""
}
func (r *Runner) Close() error {
// The journal first: it holds no buffer (runevents.Journal writes each line straight through), so this
// only returns the descriptor — nothing that could still be lost is waiting in it.
_ = r.events.close()
return r.Store.Close()
}
// bookCeilingUSD is the ONE definition of "the book ceiling in force for this process" (row 145): the
// run-scoped override when the invocation named one, the book's own `ceilings.book_usd` otherwise. Every
// surface that admits a spend or reports the cap reads it here, so the ledger can never admit against one
// number while the operator is shown another. A run-scoped override is only ever an amount > 0 (the CLI
// refuses zero/negative/non-finite), so the flag can only ever ADD a bound, never remove one — which is
// what leaves Р7 intact without amending config.LoadBook's validator. Р7 is a DIFFERENT rule: it demands
// at least one of book_usd/day_usd, so a book may legitimately declare a DAILY ceiling only, this returns
// 0 (no book ceiling, exactly as before the flag existed), and passing the flag introduces one for the run.
func (r *Runner) bookCeilingUSD() float64 {
if r.CeilingUSD > 0 {
return r.CeilingUSD
}
return r.Book.Ceilings.BookUSD
}
func (r *Runner) loadTemplates() error {
for _, st := range r.Pipeline.Stages {
// The path was resolved at load — by the pair/role convention, or by the stage's deliberate
// prompt_override — and a pair with no prompt pack already failed there, naming the file it
// looked for (D39.23). Here it is only read.
tpl, err := LoadPromptTemplate(st.PromptPath)
if err != nil {
return err
}
// Resolve the effective system once per stage: fold the few-shot block into
// System unless the stage switched it off (D38.4). FewShot is retained so the
// snapshot can fold the on/off state only for stages that actually have a block.
tpl.System = tpl.SystemFor(fewShotEnabled(st))
r.templates[st.Name] = tpl
}
return nil
}
// headingRule is the book's deterministic chapter-heading rule (pack-13 title policy), or nil when the
// book declares no langpack, its pair has no catalog, or the pair ships no heading.txt. The chunker uses
// it to detect+strip a source header and render «Глава N»; nil ⇒ the feature is inert (byte-identical
// chunks). Its bytes ride pack.Version() (folded into the snapshot via LangpackVersion), so a rule edit is
// a loud --resnapshot and a book without a rule is never re-billed for a feature it does not use.
func (r *Runner) headingRule() *lang.HeadingRule {
if r.pack != nil {
return r.pack.Heading
}
return nil
}
// sentenceAbbrevs is the BOOK's source-language abbreviation set for the sentence splitter (data-driven —
// no hard-coded "en"; a CJK source ships none, so the set is empty and never consulted).
func (r *Runner) sentenceAbbrevs() map[string]bool { return lang.SentenceAbbrev(r.Book.SourceLang) }
// estOutTokens is the DISPLAY-ONLY fertility estimate of a text's ru output tokens (est_out = fertility
// over the source char-classes, research/20 — NOT the char/4 EstimateTokens whose CJK undercount is the
// «CJK-mine»). Used ONLY to annotate a settle-estimate telemetry row (pack-13 point-9): it feeds NOTHING
// on the money path (not the reservation, not CostUSD), so it can never shift a verdict or a wire byte.
func (r *Runner) estOutTokens(text string) int {
cjk, other := chunk.TokenClassCounts(text)
return int(r.segBudget().EstOut(cjk, other))
}
// segBudget resolves the pipeline's config.Segmentation into the pipeline-package chunk.SegBudget the
// chunker consumes (WS2). Kept in the runner (not chunker.go) so the segmenter stays config-free.
func (r *Runner) segBudget() chunk.SegBudget {
seg := r.Pipeline.Segmentation
return chunk.SegBudget{
DraftBudgetOut: float64(seg.DraftBudgetOut),
EditCeilingOut: float64(seg.EditCeilingOut),
FertCJK: seg.Fertility.CJK,
FertOther: seg.Fertility.Other,
}
}
// reachableModels enumerates every model a run can call, LABEL ROUTING APPLIED: the resolved stage
// models, their resolved single hops, and the repair model when that gate is on (config.ReachableModels
// is the one definition — the capability invariant, the API-key preflight, the rate guards and this
// eager client set all read it, so a model cannot be reachable for one and invisible to another).
// The set is complete and static for a book, which is what lets the precompute pass pre-build every
// client; a NEW call path must extend that definition, not this wrapper. Deterministic order.
func (r *Runner) reachableModels() []string {
return r.Pipeline.ReachableModels()
}
// sourcePlaceholder is the template marker that puts the SOURCE text on the wire (render.go's vals map).
// A stage whose template carries it sends the raw source to its model — which is what makes the echo
// class below reachable for that stage, editor included since the editor became bilingual (D30.1).
const sourcePlaceholder = "{{text}}"
// sourceEchoExposure lists `stage→model` pairs that ship the SOURCE text to a model whose RESOLVED wire
// actively SUPPRESSES thinking — the other half of the reasoning-off hole (D39.26 добор B). The money
// half is a hard load gate (config: an additive provider that thinks needs a reserved buffer); this half
// cannot be a hard gate, because whether suppression actually costs quality is an open MEASUREMENT
// (D19.1: reasoning-ON does not save archaic dense zh either) and the choice of editor is the owner's.
// So it is made LOUD instead of silent, and the always-on downstream echo detector (classify →
// cjk_artifact, mandatory on every configuration per D19.2) remains the enforcement.
//
// Generic by construction: no provider or label name appears here — it is "this stage sends the source"
// × "this model's wire suppresses thinking" × "the source script is one where the class was measured".
// It fires on no shipping path today (deepseek resolves to ReasoningNone, so "off" is a no-op and
// thinking stays ON) and does fire for a reasoning-off editor arm on a CJK source, which is exactly the
// exposure the stale "the editor only ever sees a Russian draft" comments used to deny.
// It covers the ENGINE'S OWN calls too, not just the book's stages. Those calls carry a knob only since
// D39.87, and the knob has an asymmetric empty value: on a ReasoningNone capability "" means the provider's
// default (thinking ON), while on an extra_body_disable one (GLM) it means thinking DISABLED — the echo zone
// — and gates.terminology.model is not restricted to a family. An unset knob pointed at such a model is
// therefore a silent thinking-off with no config line describing it, which is precisely what this walk
// exists to refuse to leave silent.
func (r *Runner) sourceEchoExposure() []string {
if !lang.IsCJKScriptLang(r.Book.SourceLang) {
return nil
}
var out []string
shipsSource := func(t *PromptTemplate) bool {
return t != nil && strings.Contains(t.System+t.FewShot+t.User, sourcePlaceholder)
}
add := func(name, model, reasoning string) {
if !r.Models.ThinksOnWire(model, reasoning) {
out = append(out, name+"→"+model)
}
}
for _, st := range r.Pipeline.Stages {
if shipsSource(r.templates[st.Name]) {
add(st.Name, st.ResolvedModel, st.Reasoning)
}
}
// The bank roles ship source WITHOUT the placeholder: their candidate blocks carry KWIC contexts cut
// from the book, which is the whole reason the roles can render a term at all. So membership is by
// construction (the template is loaded ⇔ the role runs), not by scanning for a marker they do not use.
bank := r.Pipeline.Gates.Terminology
if r.terminologyTemplate != nil {
add(roleTerminologist, bank.Model, bank.Reasoning)
}
if r.classifierTemplate != nil {
add(roleClassifier, bank.ClassifierModel(), bank.Reasoning)
}
// Repair sends the source SPAN through the ordinary placeholder, per class — so it is scanned like a
// stage, and a class whose prompt is monolingual is correctly not listed.
for class, t := range r.repairTemplates {
if shipsSource(t) {
add(roleRepair+"/"+string(class), r.Pipeline.Gates.Repair.Model, r.Pipeline.Gates.Repair.Reasoning)
}
}
sort.Strings(out) // map iteration above — the warning must not reorder between runs
return out
}
// labelsFor returns the content labels of the text this run sends — BOOK-CONSTANT in v1 (D39.26 point 4).
// The signature takes no chunk ON PURPOSE: per-chapter labels would need the per-stage model fold moved
// off the per-wave snapshot (one snapshot is rendered per wave, before the chunk loop), so promising a
// per-chunk shape here would promise what the mechanism does not hold. Granularity is an owner decision
// (a labelled book routes ENTIRELY through label-capable models, which is what makes the ToS guarantee
// structural rather than dependent on the accuracy of manual per-chapter reading).
func (r *Runner) labelsFor() []string {
return r.Book.ContentLabels
}
// buildClients EAGER-constructs every reachable LLM client BEFORE any wave goroutine starts (the precompute pass).
// After this the clients map is READ-ONLY in the waves, so r.client is lock-free and a miss is a
// loud error, not a lazy build under a data race — closing the runner.go lazy-init race (D12) and
// tripwiring a future un-enumerated model axis. Idempotent; BuildClient needs only the provider
// config (not the API key), so eager-build is safe even on a key-less resume.
func (r *Runner) buildClients() error {
for _, m := range r.reachableModels() {
if _, ok := r.clients[m]; ok {
continue
}
c, err := BuildClient(r.Models, m, r.Log)
if err != nil {
return fmt.Errorf("pipeline: eager-build client for model %q (the precompute pass): %w", m, err)
}
r.clients[m] = c
}
return nil
}
// clientFor returns the PRE-BUILT client for a model, and REFUSES when that model's provider may not
// receive the labels of the content about to be sent. The label set is a PARAMETER rather than something
// the function reads for itself, and this is the only way to obtain a client: a new call path physically
// cannot reach a provider without stating what it is sending (the compile-enforced half of the routing
// guarantee, D39.26 point 8). Load-time validation already refuses such a config, so a failure here means
// a path bypassed it — an infra error, never a content verdict, so the book pauses durably (D4) instead
// of flagging a chunk.
//
// Read-only (no lazy build, no lock): buildClients constructed every reachable client in the precompute
// pass, so the map is only read in the waves; a miss means an un-enumerated model reached the wire and is
// a loud error, never a silent lazy build under a race.
func (r *Runner) clientFor(model string, labels []string) (llm.LLMClient, error) {
if missing := r.Models.MissingLabels(model, labels); len(missing) > 0 {
return nil, fmt.Errorf("pipeline: refusing to send content labelled %v to model %q (provider %s) — it does not accept %v. A provider without the label must not RECEIVE such content at all, whatever it would answer (D39.25); this is a routing bug: load-time validation should have caught it",
labels, model, r.Models.ProviderOf(model), missing)
}
if c, ok := r.clients[model]; ok {
return c, nil
}
return nil, fmt.Errorf("pipeline: no pre-built client for model %q — the precompute pass buildClients enumerates the resolved stage models their hops the repair model; a model outside that set reached the wire, add it to the eager set to keep the waves race-free", model)
}