1146 lines
69 KiB
Go
1146 lines
69 KiB
Go
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
|
||
// miner∪banknote 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 12–22%). 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
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
if p.Gates.Repair.Enabled {
|
||
add(p.Gates.Repair.Model)
|
||
}
|
||
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 {
|
||
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"
|