package config import ( "bytes" "fmt" "os" "path/filepath" "strings" "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"` } // 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"` } // 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 `//.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 // when it thinks (reasoning low|medium|high) on an ADDITIVE-billing provider (xAI — // reasoning bills on top of completion, D13.6). Required (>0) in exactly that case // (fail-fast in LoadPipeline); ignored for subset providers / reasoning-off. 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"` // Channel selects the safety channel: "" | sfw (default) | adult (18+). An // adult stage is isolated by TYPE (D4.1): its model AND its escalate_to must be // on permissive providers, enforced at load — never a fall-through to a refusing // SFW provider. Channel string `yaml:"channel"` // 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"` } // 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"` // 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 (`//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; channel-aware filtering (Anthropic out of // 18+, etc.) is applied by the runner, not the config. 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 } // CheckAdultChannel enforces the D20.2-Q3 load-time consistency lint: a book marked // adult:true MUST route through at least one channel:adult stage, else the 18+ text would // silently run on the SFW pipeline (all-SFW providers that refuse/excise explicit content). // Adult stays wire/verdict-NEUTRAL (no snapshot layer — D20.2-Q3: the first runtime consumer // assigns meaning; here it is only a routing sanity check, never a hash input). The REVERSE // (a channel:adult stage in an adult:false book) is deliberately NOT gated: the stage's // channel already forces a permissive provider (D4.1), and Adult carries no assigned meaning // yet, so a permissive stage may legitimately exist before the book flag is set. func (p *Pipeline) CheckAdultChannel(bookAdult bool) error { if !bookAdult { return nil } for _, st := range p.Stages { if st.Channel == "adult" { return nil } } return fmt.Errorf("book adult:true but no stage has channel:adult — 18+ text would go through the SFW channel (SFW providers refuse/cut explicit); add a channel:adult stage on a permissive provider (D4.1/D20.2-Q3) or set adult:false") } // LoadPipeline reads and validates a pipeline config against the models known to models.yaml, for a // book of the given language pair ("zh-ru"). The pair selects two things at load time: the PAIR LAYER // (`/pairs/.yaml` — segmentation calibration and the excision corridor, optional) // and each stage's PROMPT, resolved by convention as `//.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. func LoadPipeline(path string, models *Models, pair 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 // (/pairs/.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) } seen := map[string]bool{} for i, st := range p.Stages { if st.Name == "" { bad("stage %d: name is required", i) } // 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 //.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 //.md (pack-15), so a pair is a DIRECTORY, not a config entry. Drop the map (put the pair calibration in configs/pairs/.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) } } switch st.Reasoning { case "", "off", "low", "medium", "high": default: 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. "May think" = any effort EXCEPT an explicit reasoning:"off" (which sends // the provider's disable — off_effort:"none" for grok): low|medium|high think, and an // OMITTED/"" effort falls to the provider DEFAULT, which for xAI-grok is "low" (thinking // ON, additive) — so "" must NOT escape the gate (self-review finding #4: the earlier // thinks-only check let it through). 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 (same effort). if st.Reasoning != "off" && st.ReasoningMaxTokens <= 0 { if models.providerReasoning(st.Model) == "additive" { bad("stage %q: reasoning=%q on additive-billing provider (model %q) requires reasoning_max_tokens>0 — xAI bills reasoning on top of completion and thinks by default unless reasoning is explicitly \"off\"; without a reserved buffer the spend ceiling is blind to the overshoot (D6.2/D13.6)", st.Name, st.Reasoning, st.Model) } 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) } } switch st.Channel { case "", "sfw", "adult": default: bad("stage %q: channel must be sfw|adult, got %q", st.Name, st.Channel) } 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) } } // D4.1: channel-B (18+) isolation is enforced by TYPE — a permissive stage // may only run on, and escalate to, a permissive provider. if st.Channel == "adult" { if !models.providerPermissive(st.Model) { bad("stage %q: channel=adult requires model %q on a permissive provider (D4.1)", st.Name, st.Model) } if st.EscalateTo != "" && !models.providerPermissive(st.EscalateTo) { bad("stage %q: channel=adult escalate_to %q must be on a permissive provider (D4.1 — no fall-through to a refusing SFW channel)", st.Name, st.EscalateTo) } } } 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 pair == "" { bad("gates.repair is enabled but the book declares no language pair — the repair prompts are resolved as //repair/.md") } else { p.Gates.Repair.PromptsDir = filepath.Join(promptsRoot, pair, repairPromptDirName) } } 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"