textmachine/backend/internal/config/echo_mine_test.go

322 lines
15 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"
"strings"
"testing"
"time"
"gopkg.in/yaml.v3"
"textmachine/backend/internal/llm"
)
// echo_mine_test.go is the §2 regression gate for the DeepSeek echo mine: on a
// provider empirically shown to ECHO the untranslated CJK source when thinking is
// OFF (traced 2026-07-04), no model may be configured to disable/suppress thinking
// at reasoning=off — that re-arms a silent refusal (HTTP 200, no translation) in
// prod. LoadModels must fail-fast on both injection surfaces, while leaving the
// SAFE GLM editor (thinking:disabled over a Russian draft) untouched.
//
// Mutation check (how this guards against a future edit re-arming the mine):
// delete the echoMineViolation call in LoadModels (or the field) and the four
// "armed" cases below stop failing — the test goes red exactly when the guard is
// removed.
// writeAndLoadModels materializes a models.yaml body and runs it through the real
// LoadModels validation (mirrors the config_test.go temp-file pattern).
func writeAndLoadModels(t *testing.T, body string) (*Models, error) {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "models.yaml")
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return LoadModels(path)
}
func TestDeepSeekEchoMineRejected(t *testing.T) {
today := time.Now().UTC().Format("2006-01-02")
// provCaps is injected under provider p; modelCaps under model m.
tmpl := func(echoProne bool, modelExtra string) string {
flag := ""
if echoProne {
flag = "\n echoes_when_thinking_off: true"
}
return fmt.Sprintf(`
prices_checked: %q
default_model: m
providers:
p:
kind: openai
base_url: http://x%s
models:
m:
provider: p
price: { input_per_m: 1, output_per_m: 2 }%s
`, today, flag, modelExtra)
}
cases := []struct {
name string
echoProne bool
modelExtra string
wantErr bool
}{
{
// The exact traced mine: the thinking-disable switch via capabilities.
name: "armed via capabilities.reasoning.extra_body_disable",
echoProne: true,
modelExtra: "\n capabilities: { reasoning: { control: extra_body_disable, off_extra_body: { thinking: { type: disabled } } } }",
wantErr: true,
},
{
// The second surface: a thinking-disable smuggled through extra_body,
// which openAIRequest merges into the wire body — the control-only guard
// would miss this, so it must be caught too.
name: "armed via extra_body thinking key",
echoProne: true,
modelExtra: "\n extra_body: { thinking: { type: disabled } }",
wantErr: true,
},
{
// The NESTED form: chat_template_kwargs.thinking:false is DeepSeek's
// documented V3.1+ disable — the most likely real arming form. A flat
// top-level scan misses it; the recursive scan must catch it (ext-review).
name: "armed via nested chat_template_kwargs.thinking (DeepSeek V3.1+)",
echoProne: true,
modelExtra: "\n extra_body: { chat_template_kwargs: { thinking: false } }",
wantErr: true,
},
{
// reasoning_effort at off also pushes thinking off the default.
name: "armed via capabilities.reasoning.effort",
echoProne: true,
modelExtra: "\n capabilities: { reasoning: { control: effort, off_effort: minimal } }",
wantErr: true,
},
{
// The SAFE GLM case: an IDENTICAL thinking-disable is legal on a provider
// that is NOT echo-prone (its editor input is a Russian draft).
name: "safe: same thinking-disable on a non-echo-prone provider",
echoProne: false,
modelExtra: "\n capabilities: { reasoning: { control: extra_body_disable, off_extra_body: { thinking: { type: disabled } } } }",
wantErr: false,
},
{
// The current benign DeepSeek state: no reasoning capability → off is a
// no-op → thinking stays at the provider default (ON).
name: "safe: echo-prone provider with the default (no) reasoning control",
echoProne: true,
modelExtra: "",
wantErr: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := writeAndLoadModels(t, tmpl(c.echoProne, c.modelExtra))
if c.wantErr {
if err == nil || !strings.Contains(err.Error(), "echo mine") {
t.Fatalf("expected fail-fast mentioning the echo mine, got %v", err)
}
return
}
if err != nil {
t.Fatalf("expected the config to load, got %v", err)
}
})
}
}
// TestBoevoyConfigDeepSeekThinkingStaysOn guards the REAL configs/models.yaml
// against a future edit arming the mine. It asserts the three load-bearing facts
// directly (unmarshalling raw to dodge the prices_checked staleness date-bomb that
// LoadModels enforces): (1) the deepseek provider is still marked echo-prone — the
// guard is inert without the flag; (2) deepseek-v4-flash resolves to ReasoningNone
// so reasoning="off" stays an off-by-omission (thinking ON by default); (3)
// echoMineViolation is empty. Arm the real config and (2)/(3) go red here, while
// tmctl itself fails loudly via LoadModels.
func TestBoevoyConfigDeepSeekThinkingStaysOn(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "configs", "models.yaml"))
if err != nil {
t.Fatalf("read boevoy models.yaml: %v", err)
}
var m Models
if err := yaml.Unmarshal(raw, &m); err != nil {
t.Fatalf("parse boevoy models.yaml: %v", err)
}
const model = "deepseek-v4-flash"
mod, ok := m.Models[model]
if !ok {
t.Fatalf("boevoy config no longer defines %s — update this guard deliberately", model)
}
prov := m.Providers[mod.Provider]
if !prov.EchoesWhenThinkingOff {
t.Fatalf("provider %s must stay marked echoes_when_thinking_off — the echo-mine guard is inert without it", mod.Provider)
}
if got := m.ResolveCapability(model).Reasoning.Control; got != llm.ReasoningNone {
t.Fatalf("%s must resolve to ReasoningNone so reasoning=off stays a no-op (thinking ON), got %q — echo mine armed", model, got)
}
if why := m.echoMineViolation(model); why != "" {
t.Fatalf("%s re-arms the echo mine: %s", model, why)
}
}
// TestBoevoyConfigEditorDsproAndGrokReasoning pins two invariants. (1) The D39.22 interim editor:
// the default editor of both boevoy pipelines is deepseek-v4-pro BILINGUAL (supersedes the D30.1
// glm-5 default; the pere-run did not crown glm-vs-dspro, dspro = judge #1 / cheaper / repairable
// defects → owner interim, glm-5 kept as pipeline-arm-glm.yaml reserve) and its reasoning is "off"
// — the LOAD-BEARING echo-mine semantic: on deepseek-v4-pro "off" resolves to ReasoningNone (a
// NO-OP), so thinking stays ON and the echo-mine is NOT armed. (2) grok-4.3 (still the channel-B
// tier / 18+ judge in models.yaml) resolves to an EXPLICIT reasoning off-switch (ReasoningEffortField
// + OffEffort "none"), NOT off-by-omission — grok's OMITTED default is "low" (it thinks, billed
// additively per xAI), and it must not trip the echo-mine guard. c2's select (judge) stage stays glm-5.
func TestBoevoyConfigEditorDsproAndGrokReasoning(t *testing.T) {
m, err := LoadModels(filepath.Join("..", "..", "configs", "models.yaml"))
if err != nil {
t.Fatalf("load boevoy models.yaml: %v", err)
}
// (2) grok-4.3 reasoning invariant (channel B / 18+ judge; unchanged by the editor flip).
const grok = "grok-4.3"
cap := m.ResolveCapability(grok)
if cap.Reasoning.Control != llm.ReasoningEffortField || cap.Reasoning.OffEffort != "none" {
t.Errorf("%s must resolve to explicit reasoning_effort:\"none\" (control=effort, off_effort=none), got control=%q off_effort=%q — control:none leaves grok's \"low\" default (thinks, additive billing)",
grok, cap.Reasoning.Control, cap.Reasoning.OffEffort)
}
if why := m.echoMineViolation(grok); why != "" {
// The guard must stay silent for grok — NOT because "the editor only sees a Russian draft" (that
// rationale died with D30.1: the editor is bilingual), but because xai carries no echo-prone flag.
// Pack-17 deliberately did NOT add one: it would outlaw grok's off-switch, whose money consequence
// (grok then thinks by default, billed additively) is the owner's call. The exposure is surfaced
// loudly instead (pipeline.Runner.sourceEchoExposure) and caught by the echo gate (D19.2).
t.Errorf("%s must not trip the echo-mine guard (xai declares no echoes_when_thinking_off): %s", grok, why)
}
// (1) D39.22 interim editor flip: deepseek-v4-pro bilingual editor, reasoning "off" (a ReasoningNone
// no-op — thinking stays ON), in both boevoy pipelines. The glm-5 default is superseded (kept as a
// reserve arm); the c2 select (judge) glm-5 slot is a DIFFERENT role and is unaffected.
for _, pf := range []string{"pipeline-c1.yaml", "pipeline-c2.yaml"} {
p, err := LoadPipeline(filepath.Join("..", "..", "configs", pf), m, "zh-ru", nil)
if err != nil {
t.Fatalf("load %s: %v", pf, err)
}
var editModel, editReasoning, selectModel string
var editFewShot *bool
for _, st := range p.Stages {
switch st.Role {
case "editor":
editModel, editReasoning, editFewShot = st.Model, st.Reasoning, st.FewShot
case "judge":
selectModel = st.Model
}
}
if editModel != "deepseek-v4-pro" {
t.Errorf("%s edit stage model = %q, want deepseek-v4-pro (D39.22 interim editor; glm-5 is the reserve arm)", pf, editModel)
}
if editReasoning != "off" {
t.Errorf("%s edit stage reasoning = %q, want \"off\" — the echo-mine semantic: on deepseek-v4-pro \"off\"=ReasoningNone (no-op) so thinking stays ON, never armed", pf, editReasoning)
}
// The reasoning editor drops the hand-written few-shot block (D38.5 / exp14 §2а: its own CoT is
// disturbed by the examples). This assertion used to live on pipeline-arm-deepseek-pro.yaml — the
// arm that swapped the editor TO deepseek-v4-pro. D39.22 made that editor the production one, so
// the arm became a copy of c1 and was retired; the guarantee moves here, onto the configs that
// actually ship it.
if editFewShot == nil || *editFewShot {
t.Errorf("%s edit stage must set few_shot:false — the reasoning editor drops the hand-written examples (D38.5), got %v", pf, editFewShot)
}
if pf == "pipeline-c2.yaml" && selectModel != "glm-5" {
t.Errorf("%s select (judge) stage must stay glm-5 (Gemini Phase-2 slot), got %q", pf, selectModel)
}
}
}
// TestSwapArmConfigs guards the WS6 editor swap-arms (D39.9 — editor = the weak link). The arms are
// CONFIG, not code: each pipeline-arm-*.yaml swaps ONLY the editor model, so a different editor-model
// resolves a different stageSnap.Model → a distinct snapshot_W2 (a separate run). This pins: (1) each arm
// loads and its editor is the arm model; (2) neither arm re-arms the echo mine; (3) neither keeps the
// few-shot block off — both surviving arms run NON-reasoning editors, for which the examples are the
// point; (4) the mistral arm's PRE-CONDITION — mistral-large-2512 carries a rate_limit so the WS1
// per-model rate-guard actually throttles it (§6(б) F4).
//
// ⚠ That every OTHER key matches production is not pinned here and cannot be, because a list of keys can
// only check what somebody wrote into it: it is TestAnArmIsTheProductionConfigWithADifferentEditor, which
// compares the loaded configs whole.
//
// ⚠ pipeline-arm-deepseek-pro.yaml is RETIRED (07.09) rather than absent by oversight: D39.22 made
// deepseek-v4-pro the production editor, so that arm isolated no variable — it was a second copy of c1
// that had to be kept in step forever, and its own pin read as "the arm is wrong" whenever c1 moved. The
// few-shot guarantee it carried moved onto c1/c2 above; the equality gate refuses to let an arm silently
// become a copy again.
// editorKeepsFewShot records, per EDITOR MODEL, whether that editor keeps the hand-written ---FEWSHOT---
// block. It is a decision, not a property a model exposes: a reasoning editor's own chain of thought is
// disturbed by worked examples (D38.5 / exp14 §2а) and drops them; the others keep them, because for a
// non-reasoning editor the examples are the point. Keyed on the model so that a new ARM inherits the
// decision and a new EDITOR has to state one — an unrecorded editor is refused, never defaulted.
var editorKeepsFewShot = map[string]bool{
"deepseek-v4-pro": false,
"glm-5": true,
"mistral-large-2512": true,
}
func TestSwapArmConfigs(t *testing.T) {
m, err := LoadModels(filepath.Join("..", "..", "configs", "models.yaml"))
if err != nil {
t.Fatalf("load boevoy models.yaml: %v", err)
}
arms, err := filepath.Glob(filepath.Join("..", "..", "configs", "pipeline-arm-*.yaml"))
if err != nil {
t.Fatal(err)
}
if len(arms) == 0 {
t.Fatal("no configs/pipeline-arm-*.yaml — this test would be enforcing nothing")
}
for _, file := range arms {
t.Run(filepath.Base(file), func(t *testing.T) {
p, lerr := LoadPipeline(file, m, "zh-ru", nil)
if lerr != nil {
t.Fatalf("load %s: %v", file, lerr)
}
var edit *Stage
for i := range p.Stages {
if p.Stages[i].Role == "editor" {
edit = &p.Stages[i]
}
}
if edit == nil {
t.Fatalf("%s has no editor stage", file)
}
// (1) the arm file NAMES its editor, and the name is the only handle a deploy flip has: the
// slug in pipeline-arm-<slug>.yaml must be part of the editor model it resolves. Derived from
// the filename rather than listed, so a new arm is covered by being named; without it the
// glob-driven checks below would pass an arm whose editor was swapped to another arm's model.
slug := strings.TrimSuffix(strings.TrimPrefix(filepath.Base(file), "pipeline-arm-"), ".yaml")
if !strings.Contains(edit.Model, slug) {
t.Errorf("%s resolves editor %q, which does not carry the file's own slug %q — an arm's name is what a deploy flip selects, so it has to say which editor it is", filepath.Base(file), edit.Model, slug)
}
// (2) echo-mine safety: the arm editor's model must not re-arm the mine.
if why := m.echoMineViolation(edit.Model); why != "" {
t.Errorf("%s editor %q re-arms the echo mine: %s", file, edit.Model, why)
}
// (3) few-shot policy, looked up BY EDITOR MODEL rather than by file: the decision belongs to
// the model, so a new arm on a known editor is covered by existing it, and a new EDITOR is
// refused until somebody records what it does with the examples.
keeps, known := editorKeepsFewShot[edit.Model]
if !known {
t.Fatalf("%s runs editor %q, whose few-shot decision is not recorded in editorKeepsFewShot — a reasoning editor drops the hand examples and a non-reasoning one keeps them, and defaulting either way would be silent (D38.5)", file, edit.Model)
}
switch {
case keeps && edit.FewShot != nil:
t.Errorf("%s: editor %q keeps the few-shot block, so few_shot must be left at its default (nil = ON), got %v", file, edit.Model, *edit.FewShot)
case !keeps && (edit.FewShot == nil || *edit.FewShot):
t.Errorf("%s: editor %q is a reasoning editor and must set few_shot:false, got %v", file, edit.Model, edit.FewShot)
}
})
}
// (4) mistral rate-guard pre-condition: mistral-large-2512 must carry a rate_limit so the WS1 wave
// per-model semaphore actually throttles it (mistral ~48% retry-fails under N-∥ without it).
if rl := m.Models["mistral-large-2512"].RateLimit; rl.MaxConcurrency <= 0 {
t.Errorf("mistral-large-2512 must configure rate_limit.max_concurrency>0 (WS6 pre-condition — the swap-arm needs the WS1 rate-guard), got %d", rl.MaxConcurrency)
}
}