package config import ( "fmt" "os" "path/filepath" "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"` Retries Retries `yaml:"retries"` Stages []Stage `yaml:"stages"` Gates Gates `yaml:"gates"` Escal Escalation `yaml:"escalation"` Fanout Fanout `yaml:"fanout"` } // 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-раскладка: стабильный // префикс / волатильный хвост — сборка Фазы 1; поля фиксируются сейчас, чтобы // схема конфига не менялась под ногами «Редакции»). type ContextAssembly struct { GlossaryInjection string `yaml:"glossary_injection"` // selective | full_prefix (Р5: обе схемы) GlossaryTokenBudget int `yaml:"glossary_token_budget"` STMDepth int `yaml:"stm_depth"` OverlapTokens int `yaml:"overlap_tokens"` 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 string `yaml:"prompt"` // path to the versioned template file PromptVersion string `yaml:"prompt_version"` Temperature float64 `yaml:"temperature"` Reasoning string `yaml:"reasoning"` // "", off, low, medium, high // 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"` } // Gates is the QA-gate config skeleton (пороги полигона; исполнение — Фаза 1). type Gates struct { Coverage CoverageGate `yaml:"coverage"` } // 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"` } // gatesEnabled reports whether any QA-gate is on. Эскалация запускается только // по провалу гейта, поэтому это сигнал «эскалация вообще достижима» (Фаза 1+). func (p *Pipeline) gatesEnabled() bool { return p.Gates.Coverage.Enabled } // 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 } // 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) for i := range p.Stages { if pr := p.Stages[i].Prompt; pr != "" && !filepath.IsAbs(pr) { p.Stages[i].Prompt = filepath.Join(dir, 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 } 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) } if st.Prompt == "" { bad("stage %q: prompt template path is required", st.Name) } else if _, err := os.Stat(st.Prompt); err != nil { bad("stage %q: prompt template %s is not readable: %v", st.Name, st.Prompt, 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) } 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) } } // 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 }