textmachine/backend/internal/config/pipeline.go

480 lines
26 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 (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
// pipeline.go loads the pipeline-core config (C1/C2… — Р2). Граница «конфиг vs
// код» зафиксирована: конфиг задаёт состав/порядок стадий, роль→модель, версии
// промптов, пороги гейтов, режим инъекции глоссария, эскалационные цепочки,
// fan-out N, токен-бюджеты сборки контекста и cache TTL. Циклы по
// главам/чанкам, ветвление по гейтам, механика эскалации/ретраев и правило
// «эскалация → re-gate → флаг» зашиты в раннер.
// 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"`
}
// Segmentation is the WS2 OUTPUT-token chunking budget (слой 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 п.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 (токен-калибровка
// полигона: русский выход zh→ru ≈1.9× входа; дефолт с запасом).
MaxOutputRatio float64 `yaml:"max_output_ratio"`
MinMaxTokens int `yaml:"min_max_tokens"`
}
// ContextAssembly holds the prompt-layout budgets (Р5-раскладка: стабильный
// префикс / волатильный хвост). 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 они снесены (a
// contextSnap structural change → §8 resnapshot manifest line).
type ContextAssembly struct {
GlossaryInjection string `yaml:"glossary_injection"` // selective | full_prefix (Р5: обе схемы)
GlossaryTokenBudget int `yaml:"glossary_token_budget"`
CacheTTL string `yaml:"cache_ttl"` // per-stage TTL override — Фаза 1
}
// Retries distinguishes the CONTENT retry (регенерация после провала гейта до
// эскалации) from transport retries (профиль в models.yaml) — спека их
// различает явно (валидация, линза 2 п.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"`
// Prompt is the LEGACY single, pair-AGNOSTIC template path (kept for tests / simple configs).
// Prompts is the pair-KEYED convention pack (D39 слой 2): src→tgt → template path, resolved by the
// book's LangPair() at load with a FAIL-LOUD on a missing pair (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). The pack is where a per-pair prompt (and its LANGUAGE — a
// property of the pack, not a hardcode) lives; today only zh-ru is populated. Exactly ONE of
// Prompt/Prompts per stage (validated in LoadPipeline). The pair→prompt binding is snapshot-folded
// transitively: the resolved template's content rides PromptSHA256 and the book's pair rides
// BriefHash (source_lang/target_lang), so a pair or content change is a loud --resnapshot.
Prompt string `yaml:"prompt"`
Prompts map[string]string `yaml:"prompts"`
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 (пороги полигона; исполнение — Фаза 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"`
}
// 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): метрика — символы без пробелов; нижняя граница —
// подозрение на вырезание, верхняя — аномалия; пороги из эксперимента 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 вне
// 18+ и т.д.) применяет раннер, не конфиг.
type Escalation struct {
Chains map[string][]string `yaml:"chains"`
BudgetUSD float64 `yaml:"budget_usd"` // адресный премиум-бюджет книги; формула — [НУЖНО РЕШЕНИЕ] п.1
}
// Fanout is the C2 skeleton: N候補 candidates per chunk (fan-out — механика
// раннера, N — конфиг).
type Fanout struct {
Candidates int `yaml:"candidates"`
}
// PromptPathFor resolves the effective prompt template path for this stage given the book's language
// pair (D39 слой 2). A pair-keyed `prompts` pack resolves the entry for `pair`, failing LOUD if the
// pair is absent (never a silent fall-through to another pair's conventions — the latent bug where a
// ja book rode the zh-parataxis prompt). A legacy single `prompt` is pair-agnostic (returned as-is).
// Callers pass Book.LangPair(). Paths are already resolved to absolute in LoadPipeline.
func (st Stage) PromptPathFor(pair string) (string, error) {
if len(st.Prompts) == 0 {
return st.Prompt, nil
}
if p, ok := st.Prompts[pair]; ok {
return p, nil
}
have := make([]string, 0, len(st.Prompts))
for k := range st.Prompts {
have = append(have, k)
}
sort.Strings(have)
return "", fmt.Errorf("stage %q: no prompt for language pair %q — the pair-keyed pack has [%s]; add a %q prompt (conventions authored in the right language) or fix the book's source_lang/target_lang (D39 слой 2: никогда молча не подставляем чужую пару)",
st.Name, pair, strings.Join(have, ", "), pair)
}
// CheckRunnable rejects a config whose mechanics the Phase-0 runner does NOT
// implement — раздельно от schema-валидации (LoadPipeline), чтобы скелеты C2/C3
// парсились и валидировались как схема, но НЕ исполнялись молча. Без этого
// гейта запуск pipeline-c2.yaml прогнал бы стадии линейно: fanout.candidates
// игнорируется, вердикт судьи утёк бы в редактуру, а деньги Opus сгорели бы на
// мусор (находка ревью; Р2 требует закрывать дыру «конфиг vs код» fail-loud).
func (p *Pipeline) CheckRunnable() error {
switch p.Core {
case "C0", "C1":
default:
return fmt.Errorf("pipeline core %q не исполним в Фазе 0 (реализованы только C0/C1 — линейный проход стадий; C2/C3 selection/fusion — механика раннера Фазы 2)", p.Core)
}
if p.Fanout.Candidates > 1 {
return fmt.Errorf("pipeline fanout.candidates=%d не исполним в Фазе 0 (fan-out N кандидатов — механика раннера Фазы 2; C1 = один черновик)", p.Fanout.Candidates)
}
// Coverage QA-гейт исполним с Вехи 2.5 (excision-детект, coverage.go): раннер
// считает его по результату стадии и эмитит excision_suspect. Бывший fail-loud
// на gates.enabled снят; корректность порогов включённого гейта проверяет
// LoadPipeline (иначе тихий no-op — против Р7).
for _, st := range p.Stages {
if st.Role == "judge" {
return fmt.Errorf("pipeline stage %q role=judge не исполним в Фазе 0 (селектор получает N кандидатов — механика Фазы 2, а раннер гонит стадии линейно)", 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, но ни одна стадия не имеет channel:adult — 18+ текст пошёл бы по SFW-каналу (SFW-провайдеры отказывают/вырезают explicit); заведите channel:adult-стадию на permissive-провайдере (D4.1/D20.2-Q3) либо поставьте adult:false")
}
// LoadPipeline reads and validates a pipeline config against the models known
// to models.yaml.
func LoadPipeline(path string, models *Models) (*Pipeline, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("config: read %s: %w", path, err)
}
var p Pipeline
if err := yaml.Unmarshal(raw, &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
}
for i := range p.Stages {
p.Stages[i].Prompt = resolvePrompt(p.Stages[i].Prompt)
for pair, pr := range p.Stages[i].Prompts {
p.Stages[i].Prompts[pair] = resolvePrompt(pr)
}
}
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
}
// WS2 segmentation defaults (ratified zh-ru, §2в): a config that omits the block gets the
// output-token budget + independently-re-derived fertility. Zero/negative → default.
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)
}
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 (версионирование промптов — Р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)
}
// Prompt (legacy single, pair-agnostic) XOR Prompts (pair-keyed pack, D39 слой 2) — exactly one.
switch {
case st.Prompt != "" && len(st.Prompts) > 0:
bad("stage %q: set either `prompt` (single) OR `prompts` (pair-keyed pack), not both", st.Name)
case st.Prompt == "" && len(st.Prompts) == 0:
bad("stage %q: a prompt is required — `prompt` (single) or a pair-keyed `prompts` pack (D39 слой 2)", st.Name)
case st.Prompt != "":
if _, err := os.Stat(st.Prompt); err != nil {
bad("stage %q: prompt template %s is not readable: %v", st.Name, st.Prompt, err)
}
default: // pair-keyed pack: every declared pair's template must be readable (deterministic order)
pairs := make([]string, 0, len(st.Prompts))
for pair := range st.Prompts {
pairs = append(pairs, pair)
}
sort.Strings(pairs)
for _, pair := range pairs {
pr := st.Prompts[pair]
if pr == "" {
bad("stage %q: prompt pack pair %q has an empty path", st.Name, pair)
} else if _, err := os.Stat(pr); err != nil {
bad("stage %q: prompt pack %q → %s is not readable: %v", st.Name, pair, pr, 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 (шаг 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)
}
}
}
if len(problems) > 0 {
return nil, fmt.Errorf("config %s:\n - %s", path, strings.Join(problems, "\n - "))
}
return &p, nil
}