package config import ( "os" "path/filepath" "strings" "testing" "time" ) // writeTmp writes a file under a temp path and returns it. func writeTmp(t *testing.T, path, body string) string { t.Helper() if err := os.WriteFile(path, []byte(body), 0o644); err != nil { t.Fatal(err) } return path } // miniModels loads a minimal valid models.yaml with a single `fake` model. func miniModels(t *testing.T) *Models { t.Helper() dir := t.TempDir() body := "prices_checked: " + time.Now().UTC().Format("2006-01-02") + ` default_model: fake providers: p: { kind: openai, base_url: http://x } models: fake: { provider: p, price: { input_per_m: 1, output_per_m: 2 } } ` m, err := LoadModels(writeTmp(t, filepath.Join(dir, "models.yaml"), body)) if err != nil { t.Fatalf("miniModels: %v", err) } return m } // prompt_pack_test.go pins the pair seam (D39 layer 2, L4-prompts-not-per-pair-zh-baked) at its // pack-15 form: a stage's prompt is resolved by CONVENTION from the book's pair and the stage's role // (prompts//.md), a pair with no pack fails LOUD at load naming the path it looked for, // and prompt_override is the deliberate pair-agnostic exception. // promptProject writes a config dir with a pair's prompt pack and returns (dir, promptsRoot). func promptProject(t *testing.T, pairs ...string) string { t.Helper() dir := t.TempDir() for _, pair := range pairs { pdir := filepath.Join(dir, "prompts", pair) if err := os.MkdirAll(pdir, 0o755); err != nil { t.Fatal(err) } for _, role := range []string{"translator", "editor"} { writeTmp(t, filepath.Join(pdir, role+".md"), "sys\n---USER---\n{{text}}") } } // The pair layer points at this project's prompts dir (the repo default is ../../prompts). pairsDir := filepath.Join(dir, "cfg", pairsDirName) if err := os.MkdirAll(pairsDir, 0o755); err != nil { t.Fatal(err) } for _, pair := range pairs { writeTmp(t, filepath.Join(pairsDir, pair+".yaml"), "pair: "+pair+"\nprompts_root: ../../prompts\n") } return dir } func draftOnlyConfig(t *testing.T, dir, extra string) string { t.Helper() cfgDir := filepath.Join(dir, "cfg") if err := os.MkdirAll(cfgDir, 0o755); err != nil { t.Fatal(err) } return writeTmp(t, filepath.Join(cfgDir, "pipeline.yaml"), ` core: C1 stages: - name: draft role: translator model: fake prompt_version: v1 `+extra) } func TestPromptResolvesByPairAndRoleConvention(t *testing.T) { dir := promptProject(t, "zh-ru", "ja-ru") cfg := draftOnlyConfig(t, dir, "") p, err := LoadPipeline(cfg, miniModels(t), "zh-ru", nil) if err != nil { t.Fatalf("zh-ru must load: %v", err) } want := filepath.Join(dir, "prompts", "zh-ru", "translator.md") if p.Stages[0].PromptPath != want { t.Fatalf("resolved prompt = %q, want %q (prompts//.md)", p.Stages[0].PromptPath, want) } // The SAME config resolves a DIFFERENT pair's conventions — the pair, not the config, selects them. p2, err := LoadPipeline(cfg, miniModels(t), "ja-ru", nil) if err != nil { t.Fatalf("ja-ru must load (its pack exists): %v", err) } if p2.Stages[0].PromptPath != filepath.Join(dir, "prompts", "ja-ru", "translator.md") { t.Fatalf("ja-ru resolved %q — a pair must get its OWN conventions", p2.Stages[0].PromptPath) } } func TestPromptMissingPairPackFailsLoud(t *testing.T) { dir := promptProject(t, "zh-ru") cfg := draftOnlyConfig(t, dir, "") _, err := LoadPipeline(cfg, miniModels(t), "ja-ru", nil) if err == nil { t.Fatal("a pair with no prompt pack must FAIL LOUD (the latent ja-runs-zh-prompt bug)") } // The error must name the missing pair, the role AND the path it looked for (operator-actionable). for _, want := range []string{"ja-ru", "translator", filepath.Join("prompts", "ja-ru", "translator.md")} { if !strings.Contains(err.Error(), want) { t.Errorf("fail-loud error must mention %q, got: %v", want, err) } } } func TestPromptOverrideIsPairAgnostic(t *testing.T) { dir := promptProject(t, "zh-ru") own := writeTmp(t, filepath.Join(dir, "own.md"), "sys\n---USER---\n{{text}}") cfg := draftOnlyConfig(t, dir, " prompt_override: "+own+"\n") for _, pair := range []string{"zh-ru", "ja-ru", "en-ru"} { p, err := LoadPipeline(cfg, miniModels(t), pair, nil) if err != nil { t.Fatalf("prompt_override must resolve for %q: %v", pair, err) } if p.Stages[0].PromptPath != own { t.Errorf("pair %q: override resolved to %q, want %q", pair, p.Stages[0].PromptPath, own) } } } func TestPromptOverrideMustBeReadable(t *testing.T) { dir := promptProject(t, "zh-ru") cfg := draftOnlyConfig(t, dir, " prompt_override: nope.md\n") if _, err := LoadPipeline(cfg, miniModels(t), "zh-ru", nil); err == nil || !strings.Contains(err.Error(), "not readable") { t.Errorf("an unreadable prompt_override must be rejected, got: %v", err) } } // TestBoevoyPipelineC1ResolvesZhRu proves the migrated prod config resolves its prompts BY PAIR for zh→ru, // and that a pair with no pack fails loud. // // ⚠ The draft stage is the ONE deliberate exception to the role convention and has been since backlog row // 140 turned the bank contour on: the banknote channel is two coordinated changes, and the second is the // prompt that instructs the model to emit the ⟦TM-BANK-v1⟧ block (D39.10). It is declared with // `prompt_override`, which is exactly what the convention keeps that key for. The assertion follows the // override rather than being dropped, so an override that appears somewhere nobody meant it still reds. func TestBoevoyPipelineC1ResolvesZhRu(t *testing.T) { models, err := LoadModels(filepath.Join("..", "..", "configs", "models.yaml")) if err != nil { t.Fatalf("load models: %v", err) } cfg := filepath.Join("..", "..", "configs", "pipeline-c1.yaml") p, err := LoadPipeline(cfg, models, "zh-ru", nil) if err != nil { t.Fatalf("load pipeline-c1: %v", err) } overrides := map[string]string{"draft": "translator-banknote.md"} for _, st := range p.Stages { file := st.Role + ".md" if o, ok := overrides[st.Name]; ok { file = o } want := filepath.Join("prompts", "zh-ru", file) if !strings.HasSuffix(st.PromptPath, want) { t.Errorf("stage %q resolved %q, want a path ending in %q", st.Name, st.PromptPath, want) } if _, err := os.Stat(st.PromptPath); err != nil { t.Errorf("stage %q: resolved prompt %q must exist: %v", st.Name, st.PromptPath, err) } } // A book of a pair the repo does not carry must fail loud (closes the ja-runs-zh-prompt bug). if _, err := LoadPipeline(cfg, models, "ja-ru", nil); err == nil { t.Error("pipeline-c1 must fail loud for ja-ru (only prompts/zh-ru exists)") } } // TestPairLayerCarriesCalibration pins the pack-15 config split: the SEGMENTATION calibration and the // excision corridor come from configs/pairs/.yaml, not from the run config, and a run config // that sets the block anyway keeps its deliberate override. func TestPairLayerCarriesCalibration(t *testing.T) { models, err := LoadModels(filepath.Join("..", "..", "configs", "models.yaml")) if err != nil { t.Fatalf("load models: %v", err) } p, err := LoadPipeline(filepath.Join("..", "..", "configs", "pipeline-c1.yaml"), models, "zh-ru", nil) if err != nil { t.Fatalf("load pipeline-c1: %v", err) } if p.Segmentation.DraftBudgetOut != 1797 || p.Segmentation.EditCeilingOut != 3200 { t.Errorf("zh-ru budgets = %d/%d, want 1797/3200 from the pair layer", p.Segmentation.DraftBudgetOut, p.Segmentation.EditCeilingOut) } if p.Segmentation.Fertility.CJK != 1.1978 || p.Segmentation.Fertility.Other != 0.3852 { t.Errorf("zh-ru fertility = %v/%v, want 1.1978/0.3852 from the pair layer", p.Segmentation.Fertility.CJK, p.Segmentation.Fertility.Other) } if got := p.Gates.Coverage.LenRatio["zh-ru"]; len(got) != 2 || got[0] != 2.2 || got[1] != 4.2 { t.Errorf("zh-ru corridor = %v, want [2.2 4.2] from the pair layer", got) } // A pair whose file carries only a corridor keeps the generic segmentation fallback. ja, err := LoadPipeline(filepath.Join("..", "..", "configs", "pipeline-c1.yaml"), models, "ja-ru", nil) if err == nil { if got := ja.Gates.Coverage.LenRatio["ja-ru"]; len(got) != 2 || got[0] != 1.4 { t.Errorf("ja-ru corridor = %v, want [1.4 2.6]", got) } } // (a ja-ru load fails on the missing prompt pack — asserted above; the corridor check is best-effort) } func TestPairConfigMismatchFailsLoud(t *testing.T) { dir := t.TempDir() pairsDir := filepath.Join(dir, pairsDirName) if err := os.MkdirAll(pairsDir, 0o755); err != nil { t.Fatal(err) } writeTmp(t, filepath.Join(pairsDir, "ja-ru.yaml"), "pair: zh-ru\n") if _, err := LoadPair(dir, "ja-ru"); err == nil || !strings.Contains(err.Error(), "zh-ru") { t.Errorf("a pair file whose `pair` disagrees with its name must fail loud, got: %v", err) } // A missing pair file is NOT an error — the layer is optional (generic fallbacks apply). if pc, err := LoadPair(dir, "en-ru"); err != nil || pc != nil { t.Errorf("a missing pair file must be (nil, nil), got %v, %v", pc, err) } } // TestEveryShippingConfigLoadsUnderThePairLayer is the pack-15 acceptance check by EXECUTION: every // config the stand actually runs (c1, the reserve/candidate arms, the c2 skeleton) must still load for // the prod pair after the calibration and the prompt paths moved out of them — and must resolve to the // SAME effective numbers and prompt files they carried inline before the split. func TestEveryShippingConfigLoadsUnderThePairLayer(t *testing.T) { models, err := LoadModels(filepath.Join("..", "..", "configs", "models.yaml")) if err != nil { t.Fatalf("load models: %v", err) } for _, name := range []string{ "pipeline-c1.yaml", "pipeline-c2.yaml", "pipeline-arm-deepseek-pro.yaml", "pipeline-arm-glm.yaml", "pipeline-arm-mistral.yaml", } { t.Run(name, func(t *testing.T) { p, err := LoadPipeline(filepath.Join("..", "..", "configs", name), models, "zh-ru", nil) if err != nil { t.Fatalf("load: %v", err) } if p.Segmentation.DraftBudgetOut != 1797 || p.Segmentation.EditCeilingOut != 3200 || p.Segmentation.Fertility.CJK != 1.1978 || p.Segmentation.Fertility.Other != 0.3852 { t.Errorf("segmentation = %+v, want the zh-ru pair calibration 1797/3200/1.1978/0.3852", p.Segmentation) } if got := p.Gates.Coverage.LenRatio["zh-ru"]; len(got) != 2 || got[0] != 2.2 || got[1] != 4.2 { t.Errorf("zh-ru corridor = %v, want [2.2 4.2]", got) } for _, st := range p.Stages { if _, err := os.Stat(st.PromptPath); err != nil { t.Errorf("stage %q: resolved prompt %q must exist: %v", st.Name, st.PromptPath, err) } } }) } } // TestRetiredPromptKeysAreRejected pins the pack-15 delta guard: yaml.v3 ignores unknown fields, so a // config still carrying the retired `prompt:` / `prompts:` keys would parse to an EMPTY override and the // convention would then resolve the role's BASE prompt — a fresh book or an arm silently running a // prompt nobody chose (the D37 class). Both keys must stop the load with the migration named. func TestRetiredPromptKeysAreRejected(t *testing.T) { dir := promptProject(t, "zh-ru") for _, tc := range []struct{ name, extra, want string }{ {"legacy single prompt", " prompt: prompts/zh-ru/translator.md\n", "`prompt:` is retired"}, {"legacy pair-keyed map", " prompts: { zh-ru: prompts/zh-ru/translator.md }\n", "`prompts:` map is retired"}, } { t.Run(tc.name, func(t *testing.T) { cfg := draftOnlyConfig(t, dir, tc.extra) _, err := LoadPipeline(cfg, miniModels(t), "zh-ru", nil) if err == nil { t.Fatal("a retired prompt key must fail loud (it is silently ignored otherwise, and the convention resolves the base prompt)") } if !strings.Contains(err.Error(), tc.want) { t.Errorf("error must name the retired key, got: %v", err) } // The migration must be actionable: name both the convention and the override. for _, hint := range []string{"/.md", "prompt_override"} { if !strings.Contains(err.Error(), hint) { t.Errorf("error must point at %q, got: %v", hint, err) } } }) } } // TestDefaultPromptsRootMatchesPairFileLayout pins delta item (3): with NO pair file the convention root // must resolve to the same directory a pair file's default would name, so the "expected …" path in the // fail-loud is the path an operator should actually create. func TestDefaultPromptsRootMatchesPairFileLayout(t *testing.T) { dir := t.TempDir() // a config dir with NO pairs/ directory at all cfgDir := filepath.Join(dir, "configs") if err := os.MkdirAll(cfgDir, 0o755); err != nil { t.Fatal(err) } cfg := writeTmp(t, filepath.Join(cfgDir, "pipeline.yaml"), ` core: C1 stages: - name: draft role: translator model: fake prompt_version: v1 `) _, err := LoadPipeline(cfg, miniModels(t), "zh-ru", nil) if err == nil { t.Fatal("no prompt pack anywhere must fail loud") } // /pairs/.yaml + ../../prompts ⇒ /prompts/zh-ru/translator.md want := filepath.Join(dir, "prompts", "zh-ru", "translator.md") if !strings.Contains(err.Error(), want) { t.Errorf("expected path must be %q (the documented layout), got: %v", want, err) } } // TestUnknownConfigKeyIsRejected pins the other half of the silent-substitution class: yaml.v3 drops // unknown fields, so a TYPO reads as "not set". `promt_override:` would leave the stage on the // convention (silently running the role's BASE prompt — an arm experiment measuring the wrong thing), // `segmantation:` would leave the pair calibration untouched. Strict decoding makes both loud. func TestUnknownConfigKeyIsRejected(t *testing.T) { dir := promptProject(t, "zh-ru") for _, tc := range []struct{ name, extra string }{ {"typo'd override key", " promt_override: whatever.md\n"}, {"typo'd top-level block", "\nsegmantation:\n draft_budget_out: 999\n"}, } { t.Run(tc.name, func(t *testing.T) { cfg := draftOnlyConfig(t, dir, tc.extra) _, err := LoadPipeline(cfg, miniModels(t), "zh-ru", nil) if err == nil { t.Fatal("an unknown config key must fail loud — silently ignored, it is a behaviour change nobody chose") } if !strings.Contains(err.Error(), "field") { t.Errorf("error should name the offending field, got: %v", err) } }) } } // TestUnknownKeyErrorNamesTheKey tightens the assertion pack-15 left loose: it is not enough that a typo // FAILS — the message must name the offending key, or an operator staring at a 200-line config learns only // that "something" is wrong. yaml.v3 renders the key in its "field X not found" text; this pins it. func TestUnknownKeyErrorNamesTheKey(t *testing.T) { dir := promptProject(t, "zh-ru") for _, tc := range []struct{ name, extra, key string }{ {"stage key", " promt_override: whatever.md\n", "promt_override"}, {"top-level block", "\nsegmantation:\n draft_budget_out: 999\n", "segmantation"}, } { t.Run(tc.name, func(t *testing.T) { cfg := draftOnlyConfig(t, dir, tc.extra) _, err := LoadPipeline(cfg, miniModels(t), "zh-ru", nil) if err == nil { t.Fatal("an unknown config key must fail loud") } if !strings.Contains(err.Error(), tc.key) { t.Fatalf("the error must NAME the offending key %q, got: %v", tc.key, err) } }) } } // TestStrictDecodeRejectsUnknownKeysInBookAndPair closes the other half of the silent-substitution class // (pack-16, sanctioned after a stand audit found zero unknown keys anywhere): a mistyped book key such as // `langpack_extned:` used to leave the book's private canon quietly absent, and a mistyped pair key left the // calibration silently not applied. func TestStrictDecodeRejectsUnknownKeysInBookAndPair(t *testing.T) { dir := t.TempDir() book := filepath.Join(dir, "book.yaml") const base = `book_id: b title: T source_lang: zh target_lang: ru pipeline: pipeline.yaml models: models.yaml source_file: source.txt ceilings: { book_usd: 1.0 } ` if err := os.WriteFile(filepath.Join(dir, "source.txt"), []byte("текст"), 0o600); err != nil { t.Fatal(err) } if err := os.WriteFile(book, []byte(base+"langpack_extned: /tmp/nope\n"), 0o600); err != nil { t.Fatal(err) } if _, err := LoadBook(book); err == nil { t.Fatal("a mistyped book key must fail loud (it silently disabled the book overlay before)") } else if !strings.Contains(err.Error(), "langpack_extned") { t.Fatalf("the error must name the offending key, got: %v", err) } // A CLEAN book must still load — strictness may not cost a legitimate config. if err := os.WriteFile(book, []byte(base), 0o600); err != nil { t.Fatal(err) } if _, err := LoadBook(book); err != nil { t.Fatalf("a clean book.yaml must still load: %v", err) } pairDir := filepath.Join(dir, "pairs") if err := os.MkdirAll(pairDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(pairDir, "zh-ru.yaml"), []byte("pair: zh-ru\nsegmentaion:\n draft_budget_out: 1\n"), 0o600); err != nil { t.Fatal(err) } if _, err := LoadPair(dir, "zh-ru"); err == nil { t.Fatal("a mistyped pair key must fail loud") } else if !strings.Contains(err.Error(), "segmentaion") { t.Fatalf("the error must name the offending key, got: %v", err) } } // TestRepairGateValidation pins the load-time contract of the repair gate (pack-16): an enabled gate that // cannot ever fire is a config error, not a silent no-op, and the repair model may not sit on a provider // whose reasoning bills additively (this block has no reasoning buffer to reserve). func TestRepairGateValidation(t *testing.T) { dir := promptProject(t, "zh-ru") cases := []struct{ name, gates, want string }{ {"zero budget", "\ngates:\n repair:\n enabled: true\n model: mini\n max_calls_per_unit: 1\n budget_usd: 0\n", "budget_usd"}, {"zero call cap", "\ngates:\n repair:\n enabled: true\n model: mini\n max_calls_per_unit: 0\n budget_usd: 1\n", "max_calls_per_unit"}, {"unknown model", "\ngates:\n repair:\n enabled: true\n model: nope\n max_calls_per_unit: 1\n budget_usd: 1\n", "not defined in models.yaml"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { cfg := draftOnlyConfig(t, dir, c.gates) _, err := LoadPipeline(cfg, miniModels(t), "zh-ru", nil) if err == nil { t.Fatal("an unusable repair gate must fail loud") } if !strings.Contains(err.Error(), c.want) { t.Fatalf("error must explain %q, got: %v", c.want, err) } }) } // A DISABLED gate is never validated — the default config path must stay silent. if _, err := LoadPipeline(draftOnlyConfig(t, dir, "\ngates:\n repair:\n enabled: false\n"), miniModels(t), "zh-ru", nil); err != nil { t.Fatalf("a disabled repair gate must not be validated: %v", err) } } // TestTerminologyGateValidatesItsTargetScript pins the load-time contract of the answer-language screen: // an enabled gate must declare the target language's script, and the name must be one Unicode defines. // The failure it guards is silent at runtime, so it has to be loud before any money moves. func TestTerminologyGateValidatesItsTargetScript(t *testing.T) { dir := promptProject(t, "zh-ru") if err := os.WriteFile(filepath.Join(dir, "prompts", "zh-ru", "terminologist.md"), []byte("role\n---USER---\n{{text}}\n"), 0o600); err != nil { t.Fatal(err) } // `mining.contrast_path` rides every case: an enabled gate with no contrast artifact is refused on its // own (backlog row 140 — both bank roles live inside the bank-mining stop), and these cases are about // the TARGET SCRIPT, not about that. gate := func(extra string) string { return "\nmining:\n contrast_path: contrast.txt\ngates:\n terminology:\n enabled: true\n model: fake\n budget_usd: 1\n" + extra } cases := []struct{ name, gates, want string }{ {"missing script", gate(""), "target_script is required"}, {"unknown script", gate(" target_script: Cyrilic\n"), "not a Unicode script name"}, {"case must be exact", gate(" target_script: cyrillic\n"), "not a Unicode script name"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { _, err := LoadPipeline(draftOnlyConfig(t, dir, c.gates), miniModels(t), "zh-ru", nil) if err == nil { t.Fatal("a terminology gate that cannot screen the answer language must fail loud") } if !strings.Contains(err.Error(), c.want) { t.Fatalf("error must explain %q, got: %v", c.want, err) } }) } p, err := LoadPipeline(draftOnlyConfig(t, dir, gate(" target_script: Cyrillic\n")), miniModels(t), "zh-ru", nil) if err != nil { t.Fatalf("a declared script must load: %v", err) } if p.Gates.Terminology.TargetScript != "Cyrillic" { t.Fatalf("the declaration must reach the gate, got %q", p.Gates.Terminology.TargetScript) } // A pair that is not in the repo declares another script the same way — no Go edit involved. if _, err := LoadPipeline(draftOnlyConfig(t, dir, gate(" target_script: Hiragana\n")), miniModels(t), "zh-ru", nil); err != nil { t.Fatalf("any Unicode script must be declarable: %v", err) } // A DISABLED gate is never validated. if _, err := LoadPipeline(draftOnlyConfig(t, dir, "\ngates:\n terminology:\n enabled: false\n"), miniModels(t), "zh-ru", nil); err != nil { t.Fatalf("a disabled terminology gate must not be validated: %v", err) } }