package pipeline import ( "errors" "fmt" "log/slog" "os" "path/filepath" "sync" "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" ) // 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 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 // 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 // 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 // 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-/) // 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.CompileCheckers(checks.DCCheckerData(r.pack), lang.TargetChecksFor(r.Book.TargetLang)) }() 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 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 { return r.Store.Close() } 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 } // 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: the union of the stage models and their // single-hop escalate_to fallbacks (stagerun.go calls r.client ONLY with model∈{st.Model, EscalateTo}). // This set is complete and static for a book, which is what lets the precompute pass pre-build every client (a third // model axis — channel B / annotator — must extend this to stay race-free). Deterministic order. func (r *Runner) reachableModels() []string { seen := map[string]bool{} var out []string add := func(m string) { if m != "" && !seen[m] { seen[m] = true out = append(out, m) } } for _, st := range r.Pipeline.Stages { add(st.Model) add(st.EscalateTo) } // The repair sub-step is the THIRD model axis this function's contract warns about: without it the // pre-built client map has no entry for the repair model and r.client returns a loud error mid-wave // (and CheckKeys would not require its API key at load). if r.Pipeline.Gates.Repair.Enabled { add(r.Pipeline.Gates.Repair.Model) } return out } // 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 } // client returns the PRE-BUILT client for a model. 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) client(model string) (llm.LLMClient, error) { 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 stage models ∪ escalate_to; a model outside that set reached the wire, add it to the eager set to keep the waves race-free", model) }