textmachine/backend/internal/config/pipeline.go

1209 lines
73 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 config
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"unicode"
"gopkg.in/yaml.v3"
)
// pipeline.go loads the pipeline-core config (C1/C2… — Р2). The "config vs
// code" boundary is fixed: the config sets the composition/order of stages,
// role→model, prompt versions, gate thresholds, the glossary-injection mode,
// escalation chains, fan-out N, context-assembly token budgets and cache TTL.
// Loops over chapters/chunks, gate branching, escalation/retry mechanics and the
// rule "escalation → re-gate → flag" are wired into the runner.
// Pipeline is the parsed pipeline-*.yaml.
type Pipeline struct {
Core string `yaml:"core"` // C0|C1|C2|C3
Version int `yaml:"version"`
Defaults PipelineDefaults `yaml:"defaults"`
Context ContextAssembly `yaml:"context"`
Segmentation Segmentation `yaml:"segmentation"`
Retries Retries `yaml:"retries"`
Stages []Stage `yaml:"stages"`
Gates Gates `yaml:"gates"`
Escal Escalation `yaml:"escalation"`
Fanout Fanout `yaml:"fanout"`
Waves Waves `yaml:"waves"`
Mining Mining `yaml:"mining"`
// ContentPolicy is the ORDERED registry of content-label policies (D39.26 point 6). Ordered, not a
// map, for one mechanical reason: Go map iteration has no order, so "which policy claims this stage"
// could not be answered deterministically — here the FIRST entry claiming a stage wins, and that is
// written down in the data file. The engine knows no label VALUE and no action beyond the two shapes
// below, so a second label is one entry here plus one accepts_labels line in models.yaml.
ContentPolicy []LabelPolicy `yaml:"content_policy"`
// ContentLabels is the book's ACTIVE label set as resolved at load (not a config key): the labels
// this pipeline was resolved against. Readers use it to tell a labelled run from an unlabelled one
// without re-reading the book.
ContentLabels []string `yaml:"-"`
// ContentProblems are the BOOK-DEPENDENT routing refusals (a label with no policy, a terminal
// label, a model that may not receive a label, a route label with no escalation budget). They are
// collected rather than returned so the caller can decide by PATH: fatal for the money/wire path,
// a loud warning for the $0 read-only projections (D20.4 — a book labelled AFTER it was paid for
// must stay inspectable, else the only way to read it is to strip the label, which is exactly the
// silent bypass the mechanism exists to prevent). Config-SHAPE errors are not here: those fail the
// load unconditionally, like every other malformed-config error.
ContentProblems []string `yaml:"-"`
}
// LabelPolicy is one entry of the ordered content-policy registry: what to do with content carrying
// this label. `action: route` sends the labelled stages to their label_models and takes the single
// escalation hop from `chain`; `action: terminal` refuses to process such a book at all — a book-level
// ENTRY refusal by DECLARED data, which is deliberately weaker than the ratified per-chunk L3 screen
// (D22.7 stays a separate precondition; this mechanism detects nothing). A future screen attaches as
// its own named policy hook, never by overloading `terminal`.
type LabelPolicy struct {
Label string `yaml:"label"`
Action string `yaml:"action"` // route | terminal
// Chain names the escalation chain (escalation.chains.<name>) whose HEAD is this label's single
// fallback hop. Exactly one hop is executed: a chain with more than one member is a loud load error
// ("multi-hop is not built"), never a silent truncation of members 2..N.
Chain string `yaml:"chain"`
}
const (
// The three policy SHAPES the engine implements. They are actions, not content classes — no label
// value appears here, and ALL THREE carry the capability invariant (every model a labelled run can
// call must accept every label of the book); they differ only in what else they do.
//
// route — re-route the claimed stages to their label_models and take the single hop from `chain`.
// allow — process on capable endpoints ONLY, changing no route: the invariant is the whole policy.
// terminal — do not process the book at all (an entry refusal by declared data, not a screen).
//
// `allow` exists because the invariant is the point of a label that needs no different model: with only
// route/terminal a label whose providers are already correct was inexpressible — route demanded a
// chain, a budget and a per-stage model it has no use for, and terminal refuses the book outright. A
// pair of labels where one re-routes and the other only asserts is exactly the ratified vocabulary
// shape, so without this value the mechanism failed its own test ("a second label is added by DATA").
LabelActionRoute = "route"
LabelActionAllow = "allow"
LabelActionTerminal = "terminal"
)
// Waves configures the wave executor's parallelism (WS1 §1б / R1): how many worker goroutines fan out
// over the draft chunks (W1) and edit units (W2). It is a TRANSPORT axis — it changes NOTHING on the wire
// (each unit of work renders identical bytes regardless of which goroutine runs it), so it is deliberately
// NOT folded into the snapshot (folding it would make a worker-count edit a spurious --resnapshot / whole-
// book re-bill). The inner per-model cap stays RateLimit.MaxConcurrency (models.yaml). Workers ≤ 0 defaults
// to 1: a wave-STRUCTURED but sequential run (W1 all drafts → W1.5 → W2 all units), deterministic and safe;
// prod sets it higher (the COGS sim assumed 8). A run with workers=1 is byte-identical in results to
// workers=N (only request_log/wire ORDER differs — the golden capture sorts to absorb that).
type Waves struct {
Workers int `yaml:"workers"`
}
// Mining configures the W1.5 bank-mining stop (WS3 / R1): the general-zh contrast corpus the WHICH-detector
// scores candidates against. It is OFF unless ContrastPath is set AND a language pack is loaded (book
// langpack_root): the miner then runs at the W1.5 boundary over the W1 drafts, emits the seed-delta +
// signature map, and STOPS for owner sign (or auto-continues on an empty delta). ContrastPath is the jieba-
// style word-freq artifact (large, deployment-specific, NOT in git), resolved relative to pipeline.yaml. It
// is NOT snapshot-folded: mining produces Source:mined PROPOSALS (status:auto, inert until owner-approved),
// so it touches no existing checkpoint's wire or verdict — only the langpack VERSION (which shapes the
// proposals) is folded, via the runner's pack.Version() (§8). Empty ContrastPath ⇒ W1.5 auto-continues.
type Mining struct {
ContrastPath string `yaml:"contrast_path"`
}
// Segmentation is the WS2 OUTPUT-token chunking budget (layer 1, L2-budget-wrong-unit fix): the
// draft-chunk and edit-unit ceilings in ru-OUTPUT tokens, plus the per-pair fertility coefficients
// that convert source char-classes to an output-token estimate (est_out = cjk·CJK + other·Other).
// Snapshot-folded (segmentationSnap), so a budget/fertility edit is a loud --resnapshot (D30.9).
// The char-class CLASSIFIER is the backend's real unicode ranges (EstimateTokens: Han|Hiragana|
// Katakana|Hangul) for generality (§0.1) — the coefficients are the only per-pair datum (authored
// per project). Zero fields fall to the ratified zh-ru defaults in LoadPipeline.
type Segmentation struct {
// DraftBudgetOut is the fine DRAFT-chunk ceiling (ru-output tokens, default 1797 → 56 chunks on
// the 25-chapter rerun). A draft chunk is the small unit for COGS/coverage/alignment.
DraftBudgetOut int `yaml:"draft_budget_out"`
// EditCeilingOut is the coarse EDIT-unit ceiling (ru-output tokens, default 3200 → 37 units).
// The edit unit is a chapter (or a greedy grouping of whole draft chunks when a chapter exceeds
// the ceiling) — the large unit the reflow editor needs for cross-chunk cohesion (D39 point 4). The
// large-chapter arm (>3200, up to 8000) is GATED behind paid Q2a span-judges (§11); the ceiling
// stays config-tunable but 3200 is the conservative ratified default (span-omission unproven).
EditCeilingOut int `yaml:"edit_ceiling_out"`
Fertility Fertility `yaml:"fertility"`
}
// Fertility holds the output-token-per-source-char coefficients (WS2). Independently re-derived on
// the rerun corpus: cjk=1.1978, other=0.3852 (R²=0.9633). A recompute is a loud --resnapshot.
type Fertility struct {
CJK float64 `yaml:"cjk"`
Other float64 `yaml:"other"`
}
// PipelineDefaults are cross-stage knobs.
type PipelineDefaults struct {
// MaxOutputRatio sizes max_tokens ≈ ratio × input tokens (eval token
// calibration: the Russian zh→ru output is ≈1.9× the input; default has headroom).
MaxOutputRatio float64 `yaml:"max_output_ratio"`
MinMaxTokens int `yaml:"min_max_tokens"`
}
// ContextAssembly holds the prompt-layout budgets (Р5-layout: stable prefix /
// volatile tail). WS2 (§2а) removed the dead STMDepth/OverlapTokens knobs:
// carryover/overlap is NOT built (D39.7/8 — metrics under the 0.126 floor), and leaving
// no-op knobs in the wire snapshot invited a silent "re-activate" — so they were removed (a
// contextSnap structural change → §8 resnapshot manifest line).
type ContextAssembly struct {
GlossaryInjection string `yaml:"glossary_injection"` // selective | full_prefix (Р5: both schemes)
GlossaryTokenBudget int `yaml:"glossary_token_budget"`
CacheTTL string `yaml:"cache_ttl"` // per-stage TTL override — Phase 1
}
// Retries distinguishes the CONTENT retry (regeneration after a gate failure,
// before escalation) from transport retries (the profile in models.yaml) — the spec
// distinguishes them explicitly (validation, lens 2 point 5).
type Retries struct {
RegenerateBeforeEscalate int `yaml:"regenerate_before_escalate"`
// RegenerateEchoBeforeEscalate allows an ECHO flag (cjk_artifact) to REGENERATE on the same model up to N
// times before the single-hop escalation. It is OPT-IN — default 0 = the current "escalate straight away"
// — because it inverts a documented premise: on a provider whose echo was DETERMINISTIC a same-model retry
// re-produced it, but D39.61 measured the 0731 flash echo STOCHASTIC per call (byte-identical requests →
// echo/not), where a re-gen recovers ~7.6× cheaper than the hop. The echo GATE (threshold/detection) is
// untouched: only the RESPONSE to an echo changes.
RegenerateEchoBeforeEscalate int `yaml:"regenerate_echo_before_escalate"`
}
// Stage is one pipeline pass.
type Stage struct {
Name string `yaml:"name"`
Role string `yaml:"role"`
Model string `yaml:"model"`
// PromptOverride is the DELIBERATE exception to the convention: an explicit template path for this
// stage, pair-agnostic, resolved relative to the run config. It is how an ARM runs a variant of a
// role's prompt (an editor arm on editor-mono.md) and how a fixture points at its own template —
// never the ordinary case, which is why the field is named for what it does.
//
// The ordinary case is CONVENTION (D39.23): a stage's prompt is `<prompts root>/<pair>/<role>.md`,
// resolved at load from the book's language pair and the stage's role into PromptPath, failing LOUD
// when that file does not exist (never a silent fall-through to another pair's conventions — the
// latent bug where a ja book rode the zh-parataxis prompt, L4-prompts-not-per-pair-zh-baked).
// Listing every pair's path in every run config was the old form of the same binding; the file
// layout carries it now, so adding a pair is a directory, not an edit of every config.
//
// The binding is snapshot-folded transitively: the resolved template's CONTENT rides PromptSHA256
// (never its path) and the book's pair rides BriefHash (source_lang/target_lang), so a pair or
// content change is a loud --resnapshot while a moved file is not.
PromptOverride string `yaml:"prompt_override"`
// LegacyPrompt / LegacyPrompts are the RETIRED pre-pack-15 keys, kept in the schema for exactly one
// reason: to be REJECTED. yaml.v3 ignores unknown fields, so a config still carrying `prompt:` or
// `prompts: {zh-ru: …}` — or a typo like `promt_override:` — would parse to an EMPTY override and the
// convention below would then quietly resolve the role's BASE prompt. The snapshot protects a book
// already in flight (PromptSHA256 moves → --resnapshot), but a FRESH book or an arm experiment would
// run on a prompt nobody chose, and the run would look normal — the D37 class of silent substitution.
// Declaring them makes the stale key LOUD at load, with the migration named.
LegacyPrompt string `yaml:"prompt"`
LegacyPrompts map[string]string `yaml:"prompts"`
// PromptPath is the RESOLVED absolute template path (convention or override), filled by
// LoadPipeline. It is not a config key.
PromptPath string `yaml:"-"`
PromptVersion string `yaml:"prompt_version"`
Temperature float64 `yaml:"temperature"`
Reasoning string `yaml:"reasoning"` // "", off, low, medium, high
// ReasoningMaxTokens is the explicit reasoning-token BUFFER reserved for this stage on an
// ADDITIVE-billing provider (xAI — reasoning bills on top of completion, D13.6). Required (>0)
// whenever the stage's RESOLVED model sits on such a provider — INCLUDING at reasoning:"off"
// (D39.26 добор B, form 2): "off" suppresses thinking only where the capability carries an
// off-switch, so exempting it was a silent exit that left the ceiling blind. Ignored for subset
// providers. A call that does suppress thinking merely over-reserves — the ceiling tightens, it never
// goes blind. It only sizes the reservation (EstimateUSD), so it is NOT part of the snapshot/wire.
ReasoningMaxTokens int `yaml:"reasoning_max_tokens"`
// EscalateTo is the SINGLE-HOP fallback model tried ONCE when this stage's
// output is a deterministic content-failure another model might fix (echo /
// excision / refusal — D12). Empty = no escalation (e.g. the editor is pinned
// per book — its style must not drift to a foreign model, D12/2605.13368). The
// fallback is its OWN model, which is the request_hash axis (a distinct
// checkpoint, never the failed primary's). Gated by escalation.budget_usd.
EscalateTo string `yaml:"escalate_to"`
// LabelModels routes this stage by CONTENT LABEL: label → the model to call instead of Model when
// the book carries that label. It sits next to `model:`/`escalate_to:` because that is where the
// stage's model choice already lives — and because the resolved value folds into the SAME per-stage
// snapshot slot, so a label that re-routes only the editor moves only the edit wave (D39.26 point 1).
// When several of the book's labels claim this stage, the FIRST policy in content_policy wins.
LabelModels map[string]string `yaml:"label_models"`
// LegacyChannel is the RETIRED `channel:` key ("" | sfw | adult), declared to be REJECTED. It was
// the engine's only content-type branch, and it went out with `permissive:` in one diff: keeping it
// would leave a config whose isolation check can no longer pass (the flag it consulted is gone).
LegacyChannel *string `yaml:"channel"`
// ResolvedModel / ResolvedHop are the models this stage will ACTUALLY call — the label routing
// applied (filled by LoadPipeline, not config keys, the PromptPath convention). Every consumer reads
// these: validation, the eager client/rate-guard set, the snapshot fold, the runner and the
// escalation hop. Resolve-then-validate is the load order that makes the label mechanism honest —
// validating the CONFIGURED model would gate a model the labelled run never calls and miss the one
// it does (D39.26 point 2). With no labels they are exactly Model and EscalateTo.
ResolvedModel string `yaml:"-"`
ResolvedHop string `yaml:"-"`
// FewShot switches the prompt's optional ---FEWSHOT--- example section on/off (D38.4).
// nil (absent) = ON: the examples are appended to the system prompt (the P1a discourse
// default). false = zero-shot: the ---FEWSHOT--- block is dropped, leaving only the core
// instructions — for a reasoning model whose own CoT is disrupted by hand-written examples
// (deepseek-thinking swap-arm, exp14 §2а). A no-op for a prompt with no ---FEWSHOT--- section.
// Wire-affecting (it changes the system message), so it is folded into the snapshot: a flip
// is a loud --resnapshot, never a silent false-hit.
FewShot *bool `yaml:"few_shot"`
}
// Gates is the QA-gate config skeleton (eval thresholds; execution — Phase 1).
type Gates struct {
Coverage CoverageGate `yaml:"coverage"`
Glossary GlossaryGate `yaml:"glossary"`
Sanitizer SanitizerGate `yaml:"sanitizer"`
RegressionGuard RegressionGuardGate `yaml:"regression_guard"`
Banknote BanknoteGate `yaml:"banknote"`
Repair RepairGate `yaml:"repair"`
Terminology TerminologyGate `yaml:"terminology"`
Voice VoiceGate `yaml:"voice"`
}
// VoiceGate controls the deterministic $0 voice flagger (pack-19, D39.55): T/V contradictions,
// flattened self-designations and forbidden lexemes in attributed replies, measured against the book's
// voice profiles and address-register journal.
//
// Opt-in and OFF by default like every other gate here, and — like gates.terminology, unlike coverage /
// sanitizer / banknote — deliberately NOT snapshot-folded even when on. The reason is the same one: it
// touches neither the wire nor a checkpoint's resolved verdict. It makes no call, changes no message and
// never becomes a disposition; its counters live in retrieval_state, which every run recomputes from the
// stored text and which self-heals on resume. Folding it would re-bill a whole wave for switching on a
// measurement — and folding its VERSION would re-bill every book of every pair for a rule edit, because
// StyleCheckVersion (the sibling it might otherwise have joined) is folded unconditionally.
//
// The accepted cost of not folding: editing a rule shifts recorded counts between runs under one
// snapshot. checks.VoiceCheckVersion is logged with the run and carried in the report so the numbers
// stay attributable to the rules that produced them — the mitigation the terminologist already uses.
type VoiceGate struct {
Enabled bool `yaml:"enabled"`
}
// TerminologyGate controls the TERMINOLOGIST role (pack-20, D39.42 п.1): between the draft wave and the
// edit wave, one cheap model reads the WHOLE book's evidence for every bank candidate at once — the merged
// minerbanknote list, the source contexts of each occurrence, the renderings the drafts produced and the
// book's own SIGNED rows — and returns ONE consolidated rendering per term. (A pair-wide genre glossary
// was a fourth input here and was removed as a class by D39.47: prescribing one register to every book of
// a pair overrides the only authority that exists — the owner's signature on THIS book's bank.) It exists
// because neither existing channel answers that question: the miner is source-side and emits no dst at
// all, and the banknote's per-chunk guesses named an overlapping entity in 3% of the mini-run's terms.
//
// Opt-in and OFF by default, like every other gate here. With it off the bank-mining stop takes exactly
// the path it took before (WHICH-only terms plus whatever the banknote join attached), no provider is
// called, and no snapshot moves. It is deliberately NOT snapshot-folded even when on, for the reason
// Mining.ContrastPath is not: it produces a FILE the owner signs, writes nothing into the bank itself, and
// reaches the wire only through mined_delta → memoryVersion → the edit-wave snapshot, which is folded
// already. Folding it would re-bill a whole wave for enabling a step that changes no existing checkpoint.
type TerminologyGate struct {
Enabled bool `yaml:"enabled"`
Model string `yaml:"model"`
// BudgetUSD is the book-wide ceiling for this call class, summed over its own checkpoints (the same
// contract as gates.repair.budget_usd). A zero budget with the gate ON is a loud config error.
BudgetUSD float64 `yaml:"budget_usd"`
// BatchRunes bounds ONE call's candidate block. 0 → terminologyDefaultBatchRunes.
BatchRunes int `yaml:"batch_runes"`
// KWICPerTerm / KWICWidth size the source contexts each candidate carries. 0 → the pair's own sizing
// (langpack terminology.txt), else the engine defaults. They are the whole reason the role can
// translate at all, and the whole reason a call is not free.
KWICPerTerm int `yaml:"kwic_per_term"`
KWICWidth int `yaml:"kwic_width"`
// TargetScript is the Unicode SCRIPT NAME of the target language ("Cyrillic", "Latin", "Han", …) the
// answer-language screen checks renderings against. REQUIRED when the gate is on, by the same contract
// as the budget and the prompt path: the failure it guards is silent by construction, so a run with
// the screen disabled would bank a foreign-language canon and report nothing.
//
// It lives in the gate — which is NOT snapshot-folded — rather than in the langpack, whose bytes move
// LangpackVersion and re-buy both waves of every book of the pair. The banknote fold reads the same
// declaration, so a book running that channel without the terminologist may set it with the gate off.
TargetScript string `yaml:"target_script"`
// Reasoning is how much the bank roles may THINK, in the engine's neutral vocabulary
// ("" | off | low | medium | high) — the same key a stage carries, because it is the same knob: the
// gate is simply the place a BOOK-LEVEL call class is configured, having no stage of its own. ONE knob
// for both roles: they share the batching, the money path and the checkpoint axis, and the difference
// between a long render and a short classification is a max_tokens difference, not an effort one — a
// second key would be split without a measurement asking for it.
//
// "" is NOT "no thinking": it means "leave the provider's default", which on a DeepSeek-shaped
// capability is thinking ON at the vendor's own effort (that default moved to `high` on 2026-07-31 and
// walled the role — D39.86), and on a GLM-shaped one (control extra_body_disable) is thinking OFF. That
// asymmetry is why the value has to be configurable here at all instead of assumed; Runner
// .sourceEchoExposure names the second case out loud at load.
Reasoning string `yaml:"reasoning"`
// PromptPath is the RESOLVED role prompt (`<prompts root>/<pair>/terminologist.md`), filled by
// LoadPipeline by the ordinary role convention. Not a config key.
PromptPath string `yaml:"-"`
// ClassifyTypes turns on the §2 type-classifier PHASE: before the terminologist renders, a focused pass
// over the same batches re-derives each candidate's type (the draft heuristic is wrong 1222%). The
// corrected type routes conformance, primes the wire block and is the banked type, so a realia surface
// mistyped as a name is no longer FORCED to a transliteration. Off → the heuristic type is used exactly as
// before, byte-identically. The phase reuses the batch sizing and target-script above.
ClassifyTypes bool `yaml:"classify_types"`
// ClassifyModel is the classifier phase's model; empty → Model. Classification is cheaper than a render,
// so an operator may point it at a smaller model.
ClassifyModel string `yaml:"classify_model"`
// ClassifyBudgetUSD is the classifier phase's OWN book-wide ceiling, separate from BudgetUSD so a classify
// overrun cannot starve the render phase and the two costs stay attributable apart. >0 required when on.
ClassifyBudgetUSD float64 `yaml:"classify_budget_usd"`
// ClassifyPromptPath is the RESOLVED classifier prompt (`<prompts root>/<pair>/classifier.md`), filled by
// LoadPipeline. Not a config key.
ClassifyPromptPath string `yaml:"-"`
}
// ClassifierModel resolves the classifier phase's model — its own if set, else the render model.
func (g TerminologyGate) ClassifierModel() string {
if g.ClassifyModel != "" {
return g.ClassifyModel
}
return g.Model
}
// RepairGate controls the addressable-defect repair sub-step (pack-16, D39.24): when enabled, a FINAL
// stage that resolved OK but whose shipped text carries a deterministically-located defect gets ONE cheap
// targeted call per defect span, whose result is applied only if it survives the guards and a
// deterministic re-gate. Opt-in and OFF by default, like every other verdict-axis gate — with the gate off
// the runner takes a byte-identical path and the snapshot payload is unchanged (repairSnap is folded
// through a nil pointer, so enabling the FEATURE never re-bills a book that does not use it).
//
// Unlike escalation, a zero budget with the gate ON is a LOUD config error rather than a silent no-op: a
// gate that cannot ever fire is the class LoadPipeline already rejects for the coverage thresholds. The
// repair model may not sit on an ADDITIVE-billing provider (xAI): reasoning there bills on top of
// completion and this block carries no reasoning_max_tokens to reserve it with, so the spend ceiling would
// be blind — the same D6.2/D13.6 hole the stage-level gate closes, answered here by refusing the provider.
type RepairGate struct {
Enabled bool `yaml:"enabled"`
Model string `yaml:"model"`
// Reasoning is how much a repair call may THINK, in the engine's neutral vocabulary
// ("" | off | low | medium | high) — its OWN key, deliberately NOT inherited from the stage whose text
// is being repaired. Inheriting was the first design and it was wrong twice over: the effort's MEANING
// is per-CONTROL (on an extra_body_disable capability "off" is a live thinking disable, on a
// ReasoningNone one it is a documented no-op), and `gates.repair.model` is independent of the stage's
// model — so a final stage carrying `reasoning: "off"` as a no-op for DeepSeek would have armed a real
// disable on a GLM repair model. It also moves the request hash, so inheritance would have re-bought
// every repair call already paid for by every book whose final stage sets the key (which is all of
// them). Unset ⇒ "" ⇒ byte-identical to the pre-key behaviour.
Reasoning string `yaml:"reasoning"`
// MaxCallsPerUnit bounds the calls one output unit may spend (the blast-radius cap); the loop itself is
// a SINGLE round — a repaired text is never re-repaired.
MaxCallsPerUnit int `yaml:"max_calls_per_unit"`
// BudgetUSD is the book-wide ceiling for this call class, summed over its own checkpoints. It is a
// PRE-CALL soft cap read without serialisation, so N parallel wave workers may overshoot it by up to
// N-1 calls: unlike the escalation cap this one deliberately does NOT hold a mutex across the provider
// call, because repair is a common-path call (escalation's mutex is justified by rarity) and a lock
// spanning the transport retry loop would serialise every worker behind one slow call.
BudgetUSD float64 `yaml:"budget_usd"`
// Classes restricts the defect classes the loop may attack; empty = the engine's ratified default set.
// The names are engine identifiers (internal/checks), validated by the runner, which is the package
// that owns both the class vocabulary and this config.
Classes []string `yaml:"classes"`
// PromptsDir is the RESOLVED directory holding one prompt per class (`<prompts root>/<pair>/repair/`),
// filled by LoadPipeline. Not a config key: the pair's prompt pack is a directory layout, not a list.
PromptsDir string `yaml:"-"`
}
// BanknoteGate controls the banknote-v1 in-band footnote channel (WS4, RATIFIED D39.10): when enabled,
// the translator MAY emit a versioned ⟦TM-BANK-v1⟧ separator + tab-delimited term lines for NEW terms
// after the translation — the DIRECT dst delivery the co-occurrence miner could not extract (蛊→гу).
// Enabling it is TWO coordinated changes: this backend gate (the runner slices the block off BEFORE the
// gates/editor, commits the cleaned draft as a derived export checkpoint, and folds banknoteSnap into the
// snapshot) AND the footnote INSTRUCTION baked into the translator prompt file (which moves PromptSHA256).
// Opt-in (default false), like the sanitizer/coverage gates; a no-op unless the prompt actually instructs
// the model to emit banknotes (a normal draft has no separator → the slice is a no-op). Its parser/slice
// VERSION is folded into banknoteSnap (verdict-axis) only when enabled, so a parser change is a loud
// --resnapshot even without a prompt edit (§4в point 6).
type BanknoteGate struct {
Enabled bool `yaml:"enabled"`
}
// RegressionGuardGate controls the post-reflow regression guard (D38 infra-pack,
// research/18 §C#5): two deterministic OBSERVABILITY flaggers over the draft→final transform —
// a length collapse and a numeric drift — that surface a reflow that dropped content or drifted a
// number (四成四=44%→«четыре десятых»). Opt-in (default false), and by design NEVER a disposition
// change: the reflow editor legitimately restructures/merges, so a hard skip would false-flag a
// good edit — a hit is recorded in the cheap-gate observability channel (retrieval-state
// n_style_flags) and surfaced in the report, never dropping the chunk. Its thresholds are code
// consts (versioned with the cheap gates), so the gate carries only an on/off switch.
type RegressionGuardGate struct {
Enabled bool `yaml:"enabled"`
}
// SanitizerGate controls the output-sanitizer (D30.3): a deterministic verdict-axis
// gate on the FINAL chunk text that flags "instant unreadability" defects no other gate
// catches — leaked service preambles, trailing note/edit blocks, markdown ### headers,
// Latin-script insertions in the Russian output, and broken/split word forms (exp12 /
// flagman §5). Opt-in (default false), following the coverage gate's discipline (D12 Q4):
// when enabled a defect flips the chunk to flagged (D2 flag+skip — the garbage output
// never commits to TM/export). Its rules are tuned PRECISION over recall (each class fires
// only on a high-confidence signal), so a premature always-on default would false-flag
// legitimate prose. The rule VERSION is folded into the snapshot only when enabled
// (sanitizerVersion, mirroring coverage), so a rule edit is a loud --resnapshot — and it
// moves to verdictSnapshotID once content-addressed resume lands (D15.2).
type SanitizerGate struct {
Enabled bool `yaml:"enabled"`
}
// GlossaryGate controls the memory-bank post-check (E1). The post-check ALWAYS runs
// (observability — it records misses into the retrieval-state), converting silent
// glossary drift into a loud, visible signal. PostcheckGate promotes a miss from a
// mere record to a DISPOSITION flag (the chunk is flagged, downstream stages skip):
// opt-in (default false = flagger), flipped on only AFTER the owner validates the
// false-flag rate on real chapters (E1) — exactly the coverage gate's opt-in discipline
// (D12 Q4). A naive/under-filled decl false-flags (research/14 §2), so a premature hard
// gate would flag-storm and train editors to ignore it.
type GlossaryGate struct {
PostcheckGate bool `yaml:"postcheck_gate"`
}
// CoverageGate v1 (Р7): the metric is non-space characters; the lower bound is
// a suspected excision, the upper an anomaly; thresholds from experiment 02.
type CoverageGate struct {
Enabled bool `yaml:"enabled"`
LenRatio map[string][]float64 `yaml:"len_ratio_bounds"` // "zh-ru": [low, high]
SentCovMin float64 `yaml:"sent_cov_min"`
MinChunkChars int `yaml:"min_chunk_chars"`
}
// Escalation holds named model chains. Which chain a run may take is decided at LOAD, not by the runner:
// an unlabelled book uses its stages' escalate_to, and a labelled one takes the head of the chain named
// by its content_policy (D39.26 point 3). A chain no policy references is inert data.
type Escalation struct {
Chains map[string][]string `yaml:"chains"`
BudgetUSD float64 `yaml:"budget_usd"` // the book's earmarked premium budget; formula — [DECISION NEEDED] point 1
}
// Fanout is the C2 skeleton: N候補 candidates per chunk (fan-out — runner
// mechanics, N is config).
type Fanout struct {
Candidates int `yaml:"candidates"`
}
// promptConventionPath is the convention that replaced the per-config pair→path listing: a stage's
// prompt is the file named after its ROLE inside its PAIR's directory.
func promptConventionPath(promptsRoot, pair, role string) string {
return filepath.Join(promptsRoot, pair, role+".md")
}
// CheckRunnable rejects a config whose mechanics the Phase-0 runner does NOT
// implement — separately from schema validation (LoadPipeline), so the C2/C3
// skeletons parse and validate as schema but are NOT executed silently. Without
// this gate, running pipeline-c2.yaml would run the stages linearly: fanout.candidates
// is ignored, the judge's verdict would leak into the edit, and Opus money would burn
// on garbage (review finding; Р2 requires closing the "config vs code" hole fail-loud).
func (p *Pipeline) CheckRunnable() error {
switch p.Core {
case "C0", "C1":
default:
return fmt.Errorf("pipeline core %q is not executable in Phase 0 (only C0/C1 are implemented — a linear pass over the stages; C2/C3 selection/fusion — Phase-2 runner mechanics)", p.Core)
}
if p.Fanout.Candidates > 1 {
return fmt.Errorf("pipeline fanout.candidates=%d is not executable in Phase 0 (fan-out of N candidates — Phase-2 runner mechanics; C1 = one draft)", p.Fanout.Candidates)
}
// Coverage QA gate is executable from Milestone 2.5 (excision detection, coverage.go): the
// runner computes it from the stage result and emits excision_suspect. The former fail-loud
// on gates.enabled has been removed; LoadPipeline checks the correctness of an enabled
// gate's thresholds (otherwise a silent no-op — against Р7).
for _, st := range p.Stages {
if st.Role == "judge" {
return fmt.Errorf("pipeline stage %q role=judge is not executable in Phase 0 (the selector receives N candidates — Phase-2 mechanics, while the runner drives stages linearly)", st.Name)
}
}
return nil
}
// CheckMiningContrast refuses a WRITE run whose bank contour is configured but whose contrast artifact is
// not on this machine. It is deliberately not part of LoadPipeline and not part of CheckRunnable: both run
// on the $0 read-only surfaces too, and a `status` that refuses because a data file is missing is the
// class D20.4 closed — a book must stay inspectable on a host that cannot run it.
//
// WHY IT IS CHECKED AT ALL, given that the run would open the file anyway: the run opens it at the
// bank-mining stop, which is AFTER the whole draft wave has been bought. A deployment that shipped the
// configs and forgot the artifact would pay for a wave to be told. Here it costs nothing and names the
// path the operator actually wrote.
func (p *Pipeline) CheckMiningContrast() error {
if !p.Gates.Terminology.Enabled || p.Mining.ContrastPath == "" {
return nil
}
if _, err := os.Stat(p.Mining.ContrastPath); err != nil {
return fmt.Errorf("the bank contour is enabled and `mining.contrast_path` names %s, which is not readable: %w — the artifact is deployment-provided and NOT in git (a jieba-style word-frequency list of the SOURCE language); without it the bank-mining stop aborts the run after the draft wave is already paid for", p.Mining.ContrastPath, err)
}
return nil
}
// ContentRoutingError renders the book-dependent routing refusals as ONE error, or nil. The caller
// decides by PATH: the money/wire path (translate/redrive) must refuse, the $0 read-only projections
// must stay usable and only warn (D39.26 point 9) — otherwise a book labelled after it was paid for
// could not be inspected at all, and the only exit would be stripping the label.
func (p *Pipeline) ContentRoutingError() error {
if len(p.ContentProblems) == 0 {
return nil
}
return fmt.Errorf("content routing (labels %v):\n - %s", p.ContentLabels, strings.Join(p.ContentProblems, "\n - "))
}
// policyFor returns the registry entry for a label, or nil.
func (p *Pipeline) policyFor(label string) *LabelPolicy {
for i := range p.ContentPolicy {
if p.ContentPolicy[i].Label == label {
return &p.ContentPolicy[i]
}
}
return nil
}
// activePolicies returns the registry entries claimed by the book's labels, IN REGISTRY ORDER — the
// precedence that decides which label re-routes a stage both labels claim.
func (p *Pipeline) activePolicies() []LabelPolicy {
var out []LabelPolicy
for _, pol := range p.ContentPolicy {
if containsLabel(p.ContentLabels, pol.Label) {
out = append(out, pol)
}
}
return out
}
// resolveContentRouting fills every stage's ResolvedModel/ResolvedHop for this book's label set and
// collects the book-dependent refusals. It runs BEFORE the per-stage validation loop so every later
// gate (model existence, the additive-reasoning reserve, the capability invariant, the eager client
// set, the snapshot fold) judges the model that will actually be called.
//
// `bad` receives CONFIG-SHAPE problems (fatal on every path — a malformed registry is broken
// regardless of which book loads it); book-dependent refusals go to ContentProblems.
func (p *Pipeline) resolveContentRouting(models *Models, labels []string, bad func(string, ...any)) {
p.ContentLabels = labels
// Registry shape first — it decides precedence, so a malformed entry cannot be resolved around.
seen := map[string]bool{}
for i, pol := range p.ContentPolicy {
switch {
case pol.Label == "":
bad("content_policy[%d]: label is required", i)
case !canonicalLabel(pol.Label):
bad("content_policy[%d]: label %q must be written lower-case and without surrounding whitespace — a book's content_labels are normalised on load, so a differently-spelled policy would never be found", i, pol.Label)
case seen[pol.Label]:
bad("content_policy[%d]: duplicate label %q — one policy per label (precedence is the order of this list)", i, pol.Label)
}
seen[pol.Label] = true
switch pol.Action {
case LabelActionRoute:
if pol.Chain == "" {
bad("content_policy[%d] (%s): action=route requires `chain:` — the escalation chain whose head is this label's single fallback hop", i, pol.Label)
break
}
chain, ok := p.Escal.Chains[pol.Chain]
switch {
case !ok:
bad("content_policy[%d] (%s): chain %q is not defined in escalation.chains", i, pol.Label, pol.Chain)
case len(chain) == 0:
bad("content_policy[%d] (%s): chain %q is empty — a route policy needs a fallback hop", i, pol.Label, pol.Chain)
case len(chain) > 1:
bad("content_policy[%d] (%s): chain %q has %d members — MULTI-HOP IS NOT BUILT (one hop = chain[0], D39.26 point 3). Shorten the chain to its head, or keep the extra members in a chain no policy references", i, pol.Label, pol.Chain, len(chain))
}
case LabelActionAllow:
// Nothing is re-routed, so a chain would be a fallback nobody can reach: refused rather than
// ignored, because an author who wrote one meant something the engine will not do.
if pol.Chain != "" {
bad("content_policy[%d] (%s): action=%s takes no chain — it changes no route, so there is no hop to take. Use action=%s if this label must re-route, or drop the chain", i, pol.Label, LabelActionAllow, LabelActionRoute)
}
case LabelActionTerminal:
if pol.Chain != "" {
bad("content_policy[%d] (%s): action=terminal takes no chain (nothing is ever called for such a book)", i, pol.Label)
}
default:
bad("content_policy[%d] (%s): action must be %s|%s|%s, got %q", i, pol.Label, LabelActionRoute, LabelActionAllow, LabelActionTerminal, pol.Action)
}
}
// label_models may only name models that exist, whether or not this book activates them: a typo in
// a route nobody takes today is a mine for the book that takes it tomorrow.
for _, st := range p.Stages {
// Sorted keys: a map range would make the ORDER of these messages differ between two identical
// loads, and a report that depends on map iteration is a norm this repo already rejects.
for _, label := range sortedLabelKeys(st.LabelModels) {
model := st.LabelModels[label]
if !canonicalLabel(label) {
bad("stage %q: label_models key %q must be written lower-case and without surrounding whitespace (book labels are normalised on load)", st.Name, label)
}
if _, ok := models.Models[model]; !ok {
bad("stage %q: label_models[%q] model %q is not defined in models.yaml", st.Name, label, model)
}
switch pol := p.policyFor(label); {
case pol == nil:
bad("stage %q: label_models[%q] has no content_policy entry — a route the registry never mentions cannot be taken", st.Name, label)
case pol.Action != LabelActionRoute:
bad("stage %q: label_models[%q] is meaningless — that label's policy is action=%s, which changes no route (only action=%s re-routes stages). Drop the entry, or change the policy", st.Name, label, pol.Action, LabelActionRoute)
}
}
}
// Default resolution: no labels ⇒ exactly the configured models (the byte-identical channel-A path).
for i := range p.Stages {
p.Stages[i].ResolvedModel = p.Stages[i].Model
p.Stages[i].ResolvedHop = p.Stages[i].EscalateTo
}
if len(labels) == 0 {
return
}
contentBad := func(format string, a ...any) {
p.ContentProblems = append(p.ContentProblems, fmt.Sprintf(format, a...))
}
// A label the registry never heard of is a stop, not a default: "route with the ordinary models" is
// exactly the silent substitution this loader rejects elsewhere.
for _, l := range labels {
if p.policyFor(l) == nil {
contentBad("book declares content label %q with no content_policy entry — declare its policy (action: %s|%s) in the run config, or drop the label", l, LabelActionRoute, LabelActionTerminal)
}
}
active := p.activePolicies()
for _, pol := range active {
if pol.Action == LabelActionTerminal {
contentBad("book declares content label %q whose policy is action=%s — this book is not processed at all (an entry refusal by declared data; it is NOT a content screen)", pol.Label, LabelActionTerminal)
}
}
// Route labels need a live escalation budget, else the refusal remedy they exist for can never fire
// (escalation is opt-in via escalation.budget_usd; 0 disables every hop) — the "gate that cannot
// fire" class this loader already rejects for the coverage/repair gates.
for _, pol := range active {
if pol.Action == LabelActionRoute && p.Escal.BudgetUSD <= 0 {
contentBad("content label %q routes through chain %q but escalation.budget_usd is %g — no hop can ever fire, so a refusal on a labelled unit would be lost as flag+skip; set a budget > 0", pol.Label, pol.Chain, p.Escal.BudgetUSD)
}
}
// The effective hop set is the ordered UNION of the active route policies' chain heads, deduped. The
// engine executes exactly ONE hop, so a union of more than one head is a hop set silently truncated —
// refused with the same message a multi-member chain gets, because it is the same thing said twice.
var heads []string
for _, pol := range active {
if pol.Action != LabelActionRoute {
continue
}
if chain := p.Escal.Chains[pol.Chain]; len(chain) > 0 && !containsLabel(heads, chain[0]) {
heads = append(heads, chain[0])
}
}
if len(heads) > 1 {
contentBad("this book's labels select %d different fallback heads %v — MULTI-HOP IS NOT BUILT (one hop = chain[0], D39.26 point 3), so the later ones could never fire. Point the active policies at one chain, or label the book with one of them", len(heads), heads)
}
hop := ""
if len(heads) == 1 {
hop = heads[0]
}
routedStages := 0
for i := range p.Stages {
routed := false
for _, pol := range active {
if m, ok := p.Stages[i].LabelModels[pol.Label]; ok {
p.Stages[i].ResolvedModel = m
routed = true
break
}
}
if !routed {
continue
}
routedStages++
// The label's chain substitutes `escalate_to` ONLY on a stage the label actually re-routes: the
// label's remedy belongs to the leg the label owns. A stage the label does not touch keeps its
// configured fallback — and if that fallback may not receive the content, the invariant below
// refuses the run by NAME instead of silently swapping the model. That asymmetry is the whole
// point of D39.26 point 1: a label that re-routes only the editor must move only the EDIT wave,
// so the paid draft still resumes at $0; an unconditional substitution moved the draft-wave
// snapshot (its folded escalate_to changed) and re-billed a wave nobody asked to change.
// Stages that may not escalate at all (D12 editor-pinned) keep no hop — a refusal there is
// terminal by contract, not by omission.
if hop != "" && p.Stages[i].EscalateTo != "" {
p.Stages[i].ResolvedHop = hop
}
}
// A route policy that re-routes NOTHING is the "gate that cannot fire" class this loader rejects
// elsewhere (coverage/repair thresholds, and the budget check above): the author declared a policy,
// a chain and a budget, and a silent no-op run would send the labelled book through the ordinary
// models with every check green.
if routedStages == 0 {
for _, pol := range active {
if pol.Action == LabelActionRoute {
contentBad("content label %q routes nothing — no stage declares `label_models[%q]`, so the policy and its chain %q are inert (the book would run on the ordinary models). Add the per-stage route, or drop the label", pol.Label, pol.Label, pol.Chain)
}
}
}
}
// checkContentRouting is the routing invariant of D39.26 point 7: for a book with label set L, EVERY
// model the run can call must accept every label in L. "Can call" is LABEL-DEPENDENT — the resolved
// stage models, the resolved hops, and the repair model when that gate is on; a configured escalate_to
// or an unreferenced chain is unreachable for this book by construction, so demanding a capability of
// it would refuse a book over a model it never touches.
func (p *Pipeline) checkContentRouting(models *Models) {
if len(p.ContentLabels) == 0 {
return
}
// A terminal label means nothing is ever called, so "which model may receive this" has no answer to
// give: the entry refusal is already reported, and piling capability complaints on top of it buries
// the one line the operator must act on.
for _, pol := range p.activePolicies() {
if pol.Action == LabelActionTerminal {
return
}
}
contentBad := func(format string, a ...any) {
p.ContentProblems = append(p.ContentProblems, fmt.Sprintf(format, a...))
}
for _, m := range p.ReachableModels() {
if missing := models.MissingLabels(m, p.ContentLabels); len(missing) > 0 {
prov := models.ProviderOf(m)
contentBad("model %q (provider %s) may not receive content label(s) %v — this book declares %v. Either route the stage to a model whose provider accepts it (label_models / the label's chain), or add the label to `accepts_labels` of provider %s if that endpoint really may receive such content",
m, prov, missing, p.ContentLabels, prov)
}
}
// A hop equal to its own stage's primary is a guaranteed repeat AND a request-hash collision (same
// stage/role/attempt-0/budget/messages ⇒ the "hop" would replay the primary's own failed checkpoint
// for free, double-counting its cost and never re-routing the refusal). The configured pair is
// already gated at load; this catches the pair the LABEL produced.
for _, st := range p.Stages {
if st.ResolvedHop != "" && st.ResolvedHop == st.ResolvedModel {
contentBad("stage %q resolves to model %q with the same fallback hop under labels %v — a same-model hop replays the primary's checkpoint instead of re-routing (pick a different chain head or label model)", st.Name, st.ResolvedModel, p.ContentLabels)
}
}
// The additive-reasoning reserve, re-judged on the models the LABEL introduced (the configured pair
// is gated in the stage loop). Without this a label that re-routes a stage onto an additive-billing
// provider would reserve no reasoning buffer and blind the spend ceiling (D6.2/D13.6).
for _, st := range p.Stages {
for _, m := range []string{st.ResolvedModel, st.ResolvedHop} {
if m == "" || (m == st.Model || m == st.EscalateTo) {
continue // judged by the unconditional stage-loop gate
}
if st.ReasoningMaxTokens <= 0 && models.providerReasoning(m) == "additive" {
contentBad("stage %q resolves to model %q on an ADDITIVE-billing provider under labels %v and declares no reasoning_max_tokens — reasoning bills on top of completion there, so the reservation would be blind (D6.2/D13.6)", st.Name, m, p.ContentLabels)
}
}
}
}
// ReachableModels is every model this run can call, label routing applied: the resolved stage models,
// their resolved single hops, and the repair model when that gate is enabled. It is the ONE definition
// of "reachable" — the capability invariant, the eager client build, the rate-guard set and the API-key
// preflight all read it, so a new call path cannot be reachable for one of them and not the others.
func (p *Pipeline) ReachableModels() []string {
var out []string
seen := map[string]bool{}
add := func(m string) {
if m != "" && !seen[m] {
seen[m] = true
out = append(out, m)
}
}
for _, st := range p.Stages {
add(st.ResolvedModel)
add(st.ResolvedHop)
}
for _, m := range p.gateModels() {
add(m)
}
return out
}
// gateModels lists the models the GATES call: the repair model and the two bank roles, each only while the
// gate that calls it is on. A book that does not run a gate must never be made to hold that gate's key.
//
// ⚠ IT IS A FUNCTION OF ITS OWN BECAUSE TWO PREFLIGHTS NEED IT AND ONLY ONE HAD IT. The bank roles were
// missing from the reachable set entirely — found by planting, and the cost is measured in a wave:
// buildClients pre-builds exactly these models and CheckKeys demands exactly their keys, both at OPEN time,
// so a bank model whose provider has no key passed the whole preflight, bought the draft wave, and died at
// the bank-mining stop with «no pre-built client». That was fixed here — and the fix reached only LABELLED
// books, because CheckKeys reads ReachableModels() in the labelled branch and hand-collects stage models in
// the other one. The other one is the shipping default. So the hole stayed open on the default path for a
// second round, under a green test that asserted the LIST rather than the preflight. One list, two readers.
func (p *Pipeline) gateModels() []string {
var out []string
if p.Gates.Repair.Enabled && p.Gates.Repair.Model != "" {
out = append(out, p.Gates.Repair.Model)
}
if p.Gates.Terminology.Enabled {
if m := p.Gates.Terminology.Model; m != "" {
out = append(out, m)
}
if p.Gates.Terminology.ClassifyTypes {
if m := p.Gates.Terminology.ClassifierModel(); m != "" {
out = append(out, m)
}
}
}
return out
}
// LoadPipeline reads and validates a pipeline config against the models known to models.yaml, for a
// book of the given language pair ("zh-ru") and content-label set. The pair selects two things at load
// time: the PAIR LAYER (`<config dir>/pairs/<pair>.yaml` — segmentation calibration and the excision
// corridor, optional) and each stage's PROMPT, resolved by convention as
// `<prompts root>/<pair>/<role>.md` unless the stage sets prompt_override. A missing convention prompt
// is a LOUD failure naming the path: a book whose pair has no prompt pack must stop, never silently run
// another pair's conventions.
//
// The labels arrive through the same seam as the pair, and for the same reason: they are book data the
// config must be resolved AGAINST, and resolving them here keeps the loader's one-error-list doctrine
// (every problem of a broken config in one message). Routing is resolved BEFORE the per-stage gates, so
// each gate judges the model that will actually be called; the book-DEPENDENT refusals land in
// ContentProblems, which the caller applies by path (fatal on write, a warning on the $0 read paths).
func LoadPipeline(path string, models *Models, pair string, labels []string) (*Pipeline, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("config: read %s: %w", path, err)
}
var p Pipeline
// STRICT decode (KnownFields): an unknown key is an ERROR, not silence. yaml.v3 drops unknown fields
// by default, which makes every typo a silent behaviour change — `promt_override:` leaves the stage on
// the convention (running the role's BASE prompt), `segmantation:` leaves the generic calibration in
// place. Both look like a normal run. The retired prompt keys are declared on Stage so their message
// can name the migration; everything else is caught here.
dec := yaml.NewDecoder(bytes.NewReader(raw))
dec.KnownFields(true)
if err := dec.Decode(&p); err != nil {
return nil, fmt.Errorf("config: parse %s: %w", path, err)
}
// Prompt paths resolve relative to the pipeline file's directory, so a
// project works from any CWD.
dir := filepath.Dir(path)
resolvePrompt := func(pr string) string {
if pr != "" && !filepath.IsAbs(pr) {
return filepath.Join(dir, pr)
}
return pr
}
// The pair layer sits next to the run config; absent → the generic fallbacks below.
pairCfg, err := LoadPair(dir, pair)
if err != nil {
return nil, err
}
// With no pair file the default root is resolved from where that file WOULD sit
// (<config dir>/pairs/<pair>.yaml), so both branches name the same directory — otherwise the
// "expected …" path in the fail-loud below points one level above the documented layout.
promptsRoot := filepath.Join(dir, pairsDirName, defaultPromptsRoot)
if pairCfg != nil {
promptsRoot = pairCfg.PromptsRoot
}
// The mining contrast artifact path is resolved like the prompts (relative to pipeline.yaml).
p.Mining.ContrastPath = resolvePrompt(p.Mining.ContrastPath)
var problems []string
bad := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) }
if p.Core == "" {
bad("core is required (C0|C1|C2|C3)")
}
if len(p.Stages) == 0 {
bad("at least one stage is required")
}
if p.Defaults.MaxOutputRatio <= 0 {
p.Defaults.MaxOutputRatio = 2.0
}
if p.Defaults.MinMaxTokens <= 0 {
p.Defaults.MinMaxTokens = 2048
}
if p.Fanout.Candidates <= 0 {
p.Fanout.Candidates = 1
}
// Wave workers default to 1 (a wave-structured but sequential, deterministic run). A negative value
// is a config typo, not a request — clamp loudly-neutral to 1 rather than fail (transport axis).
if p.Waves.Workers <= 0 {
p.Waves.Workers = 1
}
// The pair layer is the source of truth for a pair's calibration; the run config may still set the
// block explicitly (a deliberate arm override), and it wins — an override written down in the run
// that produced a book must not be silently replaced by a later edit of the pair file.
if pairCfg != nil {
if p.Segmentation.DraftBudgetOut <= 0 {
p.Segmentation.DraftBudgetOut = pairCfg.Segmentation.DraftBudgetOut
}
if p.Segmentation.EditCeilingOut <= 0 {
p.Segmentation.EditCeilingOut = pairCfg.Segmentation.EditCeilingOut
}
if p.Segmentation.Fertility.CJK <= 0 {
p.Segmentation.Fertility.CJK = pairCfg.Segmentation.Fertility.CJK
}
if p.Segmentation.Fertility.Other <= 0 {
p.Segmentation.Fertility.Other = pairCfg.Segmentation.Fertility.Other
}
// The corridor enters the gate's pair-keyed map under THIS book's pair, so the gate stays
// pair-agnostic (it looks a pair up) while the numbers live with the pair.
if b := pairCfg.Coverage.LenRatioBounds; len(b) == 2 {
if _, set := p.Gates.Coverage.LenRatio[pair]; !set {
if p.Gates.Coverage.LenRatio == nil {
p.Gates.Coverage.LenRatio = map[string][]float64{}
}
p.Gates.Coverage.LenRatio[pair] = b
}
}
}
// Segmentation calibration (pair-14 §7). These numbers are the zh-ru PAIR CALIBRATION — the budgets
// tuned for zh→ru and the fertility (est_out per source char-class) independently re-derived on the zh-ru
// rerun (R²=0.96). They are NOT a language-neutral engine constant: a new pair must set its OWN
// calibration in its pair-config, so every SHIPPING pipeline (pipeline-c1 / the arm yamls) sets the whole
// block explicitly — the pair-config is the source of truth ("brать из пар-конфига"). The literals below
// are only the last-resort GENERIC FALLBACK for a config that omits the block. The values are held EXACT
// on purpose: a book with no langpack chunks entirely on these (the ja→ru golden fixture relies on this
// fallback — a re-derived number would shift its chunk boundaries → the wire). Relocating the canonical
// zh-ru calibration into the langpack was considered and NOT done: it would be dead for every live path
// (shipping configs set the block; the golden has no langpack to read it from) — least-mechanism §12.1.
if p.Segmentation.DraftBudgetOut <= 0 {
p.Segmentation.DraftBudgetOut = 1797
}
if p.Segmentation.EditCeilingOut <= 0 {
p.Segmentation.EditCeilingOut = 3200
}
if p.Segmentation.Fertility.CJK <= 0 {
p.Segmentation.Fertility.CJK = 1.1978
}
if p.Segmentation.Fertility.Other <= 0 {
p.Segmentation.Fertility.Other = 0.3852
}
// An edit unit is a grouping of WHOLE draft chunks, so the edit ceiling must be ≥ the draft
// budget — otherwise a single draft chunk already exceeds the unit ceiling and every unit is
// one chunk (the decoupling collapses). Loud config error, not a silent degenerate segmentation.
if p.Segmentation.EditCeilingOut < p.Segmentation.DraftBudgetOut {
bad("segmentation.edit_ceiling_out (%d) must be ≥ draft_budget_out (%d) — an edit unit groups whole draft chunks (WS2)",
p.Segmentation.EditCeilingOut, p.Segmentation.DraftBudgetOut)
}
switch p.Context.GlossaryInjection {
case "", "selective", "full_prefix":
default:
bad("context.glossary_injection must be selective|full_prefix, got %q", p.Context.GlossaryInjection)
}
// Content routing FIRST: every gate below judges ResolvedModel/ResolvedHop, so the labelled run is
// validated on the models it calls (D39.26 point 2). With no labels this is a no-op assignment of
// the configured models.
p.resolveContentRouting(models, labels, bad)
seen := map[string]bool{}
for i, st := range p.Stages {
if st.Name == "" {
bad("stage %d: name is required", i)
}
// The retired content-type branch. It went out together with `permissive:`: the isolation check
// it fed consulted that flag, so a surviving `channel:` would name a gate that can no longer pass.
if st.LegacyChannel != nil {
bad("stage %q: `channel:` is retired — content properties are declared as book DATA (`content_labels:`) and matched against provider `accepts_labels:`, with routing in `content_policy` + the stage's `label_models` (D39.25/D39.26). Drop the key", st.Name)
}
// A retired prompt key is a MIGRATION error, never a silently ignored one (see Stage.LegacyPrompt).
if st.LegacyPrompt != "" {
bad("stage %q: `prompt:` is retired — a stage's prompt is resolved by convention as <prompts root>/<pair>/<role>.md (pack-15). Move the file there, or set `prompt_override:` if this stage deliberately runs a variant", st.Name)
}
if len(st.LegacyPrompts) > 0 {
bad("stage %q: the pair-keyed `prompts:` map is retired — a stage's prompt is resolved by convention as <prompts root>/<pair>/<role>.md (pack-15), so a pair is a DIRECTORY, not a config entry. Drop the map (put the pair calibration in configs/pairs/<pair>.yaml); set `prompt_override:` only if this stage deliberately runs a variant", st.Name)
}
// Prompt resolution: the deliberate override, else the pair/role convention. The convention
// path must EXIST — a missing file is the "this pair has no prompt pack" case, and it stops the
// load naming the path it looked for.
switch {
case st.PromptOverride != "":
p.Stages[i].PromptPath = resolvePrompt(st.PromptOverride)
case st.Role == "":
// reported below by the role check; nothing to resolve
case pair == "":
bad("stage %q: no language pair to resolve a prompt for — the book must declare source_lang/target_lang, or the stage must set prompt_override", st.Name)
default:
cp := promptConventionPath(promptsRoot, pair, st.Role)
if _, serr := os.Stat(cp); serr != nil {
bad("stage %q: no prompt for pair %q role %q — expected %s (conventions authored in the right language). Add that file, or set prompt_override; never silently substitute another pair's conventions (D39 layer 2)",
st.Name, pair, st.Role, cp)
}
p.Stages[i].PromptPath = cp
}
if seen[st.Name] {
bad("stage %q: duplicate name", st.Name)
}
seen[st.Name] = true
if st.Role == "" {
bad("stage %q: role is required", st.Name)
}
if st.PromptVersion == "" {
bad("stage %q: prompt_version is required (prompt versioning — Р2)", st.Name)
}
if _, ok := models.Models[st.Model]; !ok {
bad("stage %q: model %q is not defined in models.yaml", st.Name, st.Model)
}
if st.PromptOverride != "" {
if _, err := os.Stat(p.Stages[i].PromptPath); err != nil {
bad("stage %q: prompt_override template %s is not readable: %v", st.Name, p.Stages[i].PromptPath, err)
}
}
if !ValidReasoningEffort(st.Reasoning) {
bad("stage %q: reasoning must be off|low|medium|high, got %q", st.Name, st.Reasoning)
}
// D13.6: on an ADDITIVE-billing provider (xAI — reasoning bills ON TOP of completion) a
// stage that MAY think needs a reserved reasoning_max_tokens buffer, else the reservation
// under-budgets and the spend ceiling is blind to the overshoot pricing.go only
// acknowledges. The gate is now UNCONDITIONAL on the effort (D39.26 добор B, the ratified second
// form "аддитивный гейт над резолвнутой моделью НЕЗАВИСИМО от off"): the previous
// `st.Reasoning != "off"` guard was a silent exit, because "off" suppresses thinking only where
// the capability carries an off-switch — with control none/mandatory nothing thinking-related
// reaches the wire and the provider default (thinking ON, billed on top of completion) stands.
// Judging the wire instead would re-open the same hole for the shape the добор names (an
// off-switch model like grok at "off"), so the buffer is required for EVERY additive-billing
// stage. Reserving a buffer a suppressed call will not spend is the CONSERVATIVE direction: the
// ceiling tightens, it never goes blind. Subset providers never need a buffer. This is the
// structural gate D6.2 requires before think-ON is allowed on grok (the contrast the task draws
// with the echo mine). Checks the primary AND the escalate_to model.
if st.ReasoningMaxTokens <= 0 {
if models.providerReasoning(st.Model) == "additive" {
bad("stage %q: model %q sits on an ADDITIVE-billing provider and requires reasoning_max_tokens>0 (reasoning=%q) — reasoning bills ON TOP of completion there, and `reasoning: \"off\"` is no exemption: it suppresses thinking only where the model declares an off-switch, so without a reserved buffer the spend ceiling can be blind to the overshoot (D6.2/D13.6, D39.26 добор B). A suppressed call merely over-reserves, which is the safe direction", st.Name, st.Model, st.Reasoning)
}
if st.EscalateTo != "" && models.providerReasoning(st.EscalateTo) == "additive" {
bad("stage %q: reasoning=%q with escalate_to on an additive-billing provider (model %q) requires reasoning_max_tokens>0 (D6.2/D13.6)", st.Name, st.Reasoning, st.EscalateTo)
}
}
if st.EscalateTo != "" {
if _, ok := models.Models[st.EscalateTo]; !ok {
bad("stage %q: escalate_to model %q is not defined in models.yaml", st.Name, st.EscalateTo)
} else if st.EscalateTo == st.Model {
bad("stage %q: escalate_to must differ from the primary model %q (a same-model hop is a guaranteed repeat)", st.Name, st.Model)
}
// D12 editor-pinned, enforced structurally: only the translator role may
// fall back to another model. An editor/other stage that escalated would
// drift its style/terms to a foreign model (2605.13368) — forbid it at load.
if st.Role != "translator" {
bad("stage %q: escalate_to is only allowed on a translator role (D12 editor-pinned — a %q stage must not fall back to a foreign model)", st.Name, st.Role)
}
}
}
// The routing invariant over the models the LABELS resolve to (D4.1's successor: isolation by DATA
// rather than by a boolean type). Runs after the stage loop so it judges fully resolved stages.
p.checkContentRouting(models)
for chain, ms := range p.Escal.Chains {
for _, m := range ms {
if _, ok := models.Models[m]; !ok {
bad("escalation chain %q: model %q is not defined in models.yaml", chain, m)
}
}
}
// Coverage QA-gate thresholds (step 6): an ENABLED gate must be able to flag
// something, otherwise it is a silent no-op that passes every chunk (against Р7).
if cov := p.Gates.Coverage; cov.Enabled {
// sent_cov_min must be a LIVE threshold in (0,1]: it is the pair-INDEPENDENT
// half of the gate, so requiring it guarantees the gate can never silently pass
// every chunk for a language pair that lacks a len_ratio corridor (self-review:
// the old "both empty" check missed the per-pair gap — a non-empty len_ratio map
// that just lacks the book's pair, with sent_cov_min=0, passed everything).
if cov.SentCovMin <= 0 || cov.SentCovMin > 1 {
bad("gates.coverage.sent_cov_min must be within (0, 1] when the gate is enabled, got %v (else the gate silently passes any language pair without a len_ratio corridor)", cov.SentCovMin)
}
for pair, b := range cov.LenRatio {
if len(b) != 2 || b[0] <= 0 || b[1] < b[0] {
bad("gates.coverage.len_ratio_bounds[%q] must be [low, high] with 0 < low <= high, got %v", pair, b)
}
}
}
// Repair gate (pack-16): an ENABLED gate must be able to fire — a model that exists, a budget that
// admits at least one call, a call cap, and a prompt directory for the pair. Everything here is
// structural; the CLASS names are validated by the runner, which owns that vocabulary (this package is
// imported BY internal/checks, so it cannot import the class constants back without a cycle).
if rep := p.Gates.Repair; rep.Enabled {
if _, ok := models.Models[rep.Model]; !ok {
bad("gates.repair.model %q is not defined in models.yaml", rep.Model)
} else if models.providerReasoning(rep.Model) == "additive" {
bad("gates.repair.model %q sits on an ADDITIVE-billing provider (reasoning bills on top of completion) and this gate carries no reasoning_max_tokens to reserve it with — the spend ceiling would be blind (D6.2/D13.6); pick a subset-billing model for repair", rep.Model)
}
if rep.BudgetUSD <= 0 {
bad("gates.repair.budget_usd must be > 0 when the gate is enabled (a gate that can never spend is a silent no-op, the class gates.coverage thresholds are already rejected for)")
}
if rep.MaxCallsPerUnit <= 0 {
bad("gates.repair.max_calls_per_unit must be > 0 when the gate is enabled")
}
if !ValidReasoningEffort(rep.Reasoning) {
bad("gates.repair.reasoning must be off|low|medium|high, got %q", rep.Reasoning)
}
if pair == "" {
bad("gates.repair is enabled but the book declares no language pair — the repair prompts are resolved as <prompts root>/<pair>/repair/<class>.md")
} else {
p.Gates.Repair.PromptsDir = filepath.Join(promptsRoot, pair, repairPromptDirName)
}
}
// Terminology gate (pack-20): same structural contract as repair — an enabled gate must be able to
// fire. The additive-billing refusal is the same D6.2/D13.6 hole: this block carries no
// reasoning_max_tokens, so a provider that bills reasoning on top of completion would make the spend
// ceiling blind.
if tg := p.Gates.Terminology; tg.Enabled {
// ⚠ THE GATE CANNOT FIRE WITHOUT A MINING CONTRAST, and until row 140 turned the contour on in a
// shipping config nobody had to say so. Both bank roles run INSIDE the bank-mining stop, which
// returns before building a single candidate when the contrast path is empty
// (pipeline.runBankMiningStop) — so a fully configured, fully budgeted terminologist under an unset
// `mining.contrast_path` is a gate that can never run: the exact class this loader refuses
// everywhere else. The file's ABSENCE is worse than an unset key, and that is why it is stat'ed
// here rather than left to the run: the run opens it AFTER the draft wave is paid for and aborts
// there, so a deployment that forgot the artifact would buy a whole wave to find out.
// ⚠ Only the KEY is judged here. Whether the FILE is on this machine is a deployment question, and
// LoadPipeline is on every path including the $0 read-only ones — refusing `status` on a book whose
// host lacks a data artifact is the D20.4 mistake. The file is checked on the WRITE path instead
// (CheckMiningContrast), before the store is opened and before any money moves.
if p.Mining.ContrastPath == "" {
bad("gates.terminology is enabled but `mining.contrast_path` is not set — both bank roles run INSIDE the bank-mining stop, which returns early without a contrast artifact, so the whole contour would be configured and never execute; set the path, or drop the gate")
}
if _, ok := models.Models[tg.Model]; !ok {
bad("gates.terminology.model %q is not defined in models.yaml", tg.Model)
} else if models.providerReasoning(tg.Model) == "additive" {
bad("gates.terminology.model %q sits on an ADDITIVE-billing provider (reasoning bills on top of completion) and this gate carries no reasoning_max_tokens to reserve it with — the spend ceiling would be blind (D6.2/D13.6); pick a subset-billing model", tg.Model)
}
if tg.BudgetUSD <= 0 {
bad("gates.terminology.budget_usd must be > 0 when the gate is enabled (a gate that can never spend is a silent no-op)")
}
// The bank roles' effort knob answers to the same vocabulary as a stage's, by the same validator —
// the gate is where a book-level call class is configured, not a second dialect of the same key.
if !ValidReasoningEffort(tg.Reasoning) {
bad("gates.terminology.reasoning must be off|low|medium|high, got %q", tg.Reasoning)
}
// Validated against the same standard-library table the runner resolves with
// (terminology.ScriptByName), so an accepted name can never fail to resolve later.
if tg.TargetScript == "" {
bad("gates.terminology.target_script is required when the gate is enabled — the Unicode script name of the target language (e.g. Cyrillic | Latin | Han); without it a reply in the wrong language is banked as this book's canon in silence")
} else if _, ok := unicode.Scripts[tg.TargetScript]; !ok {
bad("gates.terminology.target_script %q is not a Unicode script name (exact spelling, e.g. Cyrillic | Latin | Han | Hiragana | Katakana | Hangul | Greek | Arabic)", tg.TargetScript)
}
if pair == "" {
bad("gates.terminology is enabled but the book declares no language pair — the role prompt is resolved as <prompts root>/<pair>/terminologist.md")
} else {
p.Gates.Terminology.PromptPath = promptConventionPath(promptsRoot, pair, terminologyRoleName)
if _, err := os.Stat(p.Gates.Terminology.PromptPath); err != nil {
bad("gates.terminology is enabled but its role prompt is missing — expected %s (authored in the pair's own language, like the other role prompts)", p.Gates.Terminology.PromptPath)
}
}
// The §2 classifier phase: same structural contract as the render phase — an enabled phase must be able
// to fire (a resolvable model, a non-additive provider, its own budget, its own prompt).
if tg.ClassifyTypes {
cm := tg.ClassifierModel()
if _, ok := models.Models[cm]; !ok {
bad("gates.terminology.classify_model %q is not defined in models.yaml", cm)
} else if models.providerReasoning(cm) == "additive" {
bad("gates.terminology.classify_model %q sits on an ADDITIVE-billing provider and this phase carries no reasoning_max_tokens to reserve it with — the spend ceiling would be blind (D6.2/D13.6); pick a subset-billing model", cm)
}
if tg.ClassifyBudgetUSD <= 0 {
bad("gates.terminology.classify_budget_usd must be > 0 when classify_types is on (a phase that can never spend is a silent no-op)")
}
if pair != "" {
p.Gates.Terminology.ClassifyPromptPath = promptConventionPath(promptsRoot, pair, classifierRoleName)
if _, err := os.Stat(p.Gates.Terminology.ClassifyPromptPath); err != nil {
bad("gates.terminology.classify_types is on but its prompt is missing — expected %s (authored in the pair's own language, like the other role prompts)", p.Gates.Terminology.ClassifyPromptPath)
}
}
}
} else if tg.ClassifyTypes {
// The classifier is a PHASE of the terminology gate — loadClassifierTemplate is reached only through
// loadTerminologyTemplate, and runClassifier only through runTerminologist, both of which return early
// when the gate is off. So classify_types under a DISABLED gate never fires and, until now, was never
// validated either: a silent no-op, the "gate that cannot fire" class this loader rejects everywhere
// else. Fail loud instead of running with a phase the operator believes is on.
bad("gates.terminology.classify_types is on but gates.terminology.enabled is false — the classifier is a phase of the terminology gate and never runs on its own; enable the gate, or drop classify_types")
}
if len(problems) > 0 {
return nil, fmt.Errorf("config %s:\n - %s", path, strings.Join(problems, "\n - "))
}
return &p, nil
}
// repairPromptDirName is the per-pair directory holding one repair prompt per defect class. The ordinary
// stage convention keys a prompt by ROLE; a repair prompt is keyed by the CLASS of defect it corrects, so
// it lives one level down rather than colliding with the role namespace.
const repairPromptDirName = "repair"
// terminologyRoleName is the ROLE the terminologist prompt is keyed by, so it resolves through the same
// `<prompts root>/<pair>/<role>.md` convention every stage role uses — it is a role of the pair's prompt
// pack, not a second prompt namespace.
const terminologyRoleName = "terminologist"
// classifierRoleName is the ROLE the §2 type-classifier prompt is keyed by, resolved through the same
// <prompts root>/<pair>/<role>.md convention as every other role prompt.
const classifierRoleName = "classifier"