textmachine/backend/internal/config/echo_mine_test.go

275 lines
11 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)
}
}
// TestBoevoyConfigEditorGlm5AndGrokReasoning pins two invariants. (1) The D30.1 flip: the
// default editor of both boevoy pipelines is glm-5 BILINGUAL (mono-editor was passive and
// blind, exp12; grok reasoning-off is REMOVED from editor roles — a no-op) and its reasoning
// is off. (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 the glm-5 placeholder.
func TestBoevoyConfigEditorGlm5AndGrokReasoning(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 != "" {
t.Errorf("%s must not trip the echo-mine guard (xAI editor input is a Russian draft, not CJK): %s", grok, why)
}
// (1) D30.1 editor flip: glm-5 bilingual editor, reasoning off, in both boevoy pipelines.
for _, pf := range []string{"pipeline-c1.yaml", "pipeline-c2.yaml"} {
p, err := LoadPipeline(filepath.Join("..", "..", "configs", pf), m)
if err != nil {
t.Fatalf("load %s: %v", pf, err)
}
var editModel, editReasoning, selectModel string
for _, st := range p.Stages {
switch st.Role {
case "editor":
editModel, editReasoning = st.Model, st.Reasoning
case "judge":
selectModel = st.Model
}
}
if editModel != "glm-5" {
t.Errorf("%s edit stage model = %q, want glm-5 (D30.1: bilingual editor; grok reasoning-off из редакторов СНЯТ — no-op)", pf, editModel)
}
if editReasoning != "off" {
t.Errorf("%s edit stage reasoning = %q, want off (glm-5 thinking:disabled — таймауты ×3)", pf, editReasoning)
}
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 — редактор = слабое звено). 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 — the deepseek-pro editor
// resolves to ReasoningNone (thinking ON, off is a no-op) so echoMineViolation stays empty on an echo-
// prone provider; (3) the deepseek reasoning-editor drops few-shot (few_shot:false), the mistral arm
// keeps it (nil = ON); (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). All three boevoy pipelines (c1 + two
// arms) still pin the same translator+draft; only the editor differs.
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 := []struct {
file, editorModel string
fewShotOff bool // the deepseek reasoning editor drops few-shot; mistral keeps it
}{
{"pipeline-arm-mistral.yaml", "mistral-large-2512", false},
{"pipeline-arm-deepseek-pro.yaml", "deepseek-v4-pro", true},
}
for _, a := range arms {
t.Run(a.file, func(t *testing.T) {
p, err := LoadPipeline(filepath.Join("..", "..", "configs", a.file), m)
if err != nil {
t.Fatalf("load %s: %v", a.file, err)
}
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", a.file)
}
if edit.Model != a.editorModel {
t.Errorf("%s editor model = %q, want the swap-arm %q", a.file, edit.Model, a.editorModel)
}
// (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", a.file, edit.Model, why)
}
// (3) few-shot policy: deepseek reasoning-editor drops the block; mistral keeps it (nil = ON).
if a.fewShotOff {
if edit.FewShot == nil || *edit.FewShot {
t.Errorf("%s deepseek reasoning-editor must set few_shot:false (drop hand examples), got %v", a.file, edit.FewShot)
}
} else if edit.FewShot != nil {
t.Errorf("%s mistral editor must keep few_shot default (nil=ON), got %v", a.file, 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)
}
}