textmachine/backend/internal/config/pipeline.go

633 lines
36 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"
"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 `<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
// 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 (`<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; 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
// (`<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.
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
// (<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)
}
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 <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)
}
}
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 <prompts root>/<pair>/repair/<class>.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"