package pipeline import ( "context" "strings" "testing" "textmachine/backend/internal/obs" ) // regressionguard_live_test.go: the post-reflow regression guard, driven through the REAL runner // (norm 11 — a sandbox reproduction through the driver, not a unit call on the flagger). // // The unit tests in internal/checks already pin the flagger's arithmetic. What was never pinned — and // what D38.4 point 5 actually obliged us to deliver — is the WIRING: the gate config reaching the cheap // gates, the edit wave passing the real DRAFT (not the final twice), and the hit landing in durable // observability. The obligation went unmet for a whole pere-run (D39.34 finding 1: `regression_guard` // was absent from every shipping config, so the guard was dark), which is exactly the class of failure a // wiring test catches and a unit test cannot. // // The fixture makes the editor do what the guard exists to catch: it returns a text that is far shorter // than the draft AND has lost a multi-digit number. Nothing else in the cheap-gate battery may fire on // this text, so the asserted counts are the guard's alone. // rgDraft is a draft long enough for the collapse ratio to be meaningful (>200 non-space chars) that // carries two multi-digit numbers. No dialogue dashes, no Latin, no ё/е pair, no CJK magnitude — so no // other cheap gate has anything to say about it. const rgDraft = "Утро выдалось тихим, и в читальном зале пахло старой бумагой. " + "Он просидел над свитками 44 минуты, пока свет не сместился к дальней стене. " + "Потом пришли остальные, и зал наполнился шорохом страниц и негромкими голосами. " + "К полудню в списке значилось 128 имён, и каждое из них требовалось сверить с описью. " + "Работа была скучной, но её надо было довести до конца, потому что иначе опись теряла смысл." // rgShortFinal is what a reflow that DROPPED content looks like: a fraction of the length, and the // numbers are gone. Under the guard this is one length collapse + two disappeared numbers. const rgShortFinal = "Утро выдалось тихим, в зале пахло бумагой." // rgProvider answers the draft with rgDraft and the edit with rgShortFinal. func rgProvider(body string) (string, string) { if isEditBody(body) { return rgShortFinal, "stop" } return rgDraft, "stop" } // regressionStyleFlags runs one book end-to-end with the given gates block and returns the leader // chunk's durable style-flag count and its JSON detail. func regressionStyleFlags(t *testing.T, gatesYAML string) (int, string) { t.Helper() rec := &reqRec{} srv := newJSONProvider(rec, rgProvider) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{ source: suzukiSource, glossarySeed: suzukiSeed, // retrieval_state rows exist only for a book with a bank gatesYAML: gatesYAML, regenerate: 0, }) r := newRunner(t, bookPath) defer r.Close() if _, err := r.TranslateBook(obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})); err != nil { t.Fatal(err) } rs, err := r.Store.GetRetrievalState("test-book", 1, 0) if err != nil { t.Fatal(err) } if rs == nil { t.Fatal("no retrieval_state row for the leader chunk — the observability channel itself is dark") } return rs.NStyleFlags, rs.StyleDetail } // TestRegressionGuardFiresLiveWhenEnabled is the obligation D38.4 point 5 left unpaid: with the gate ON // a reflow that halved the text and lost its numbers must be VISIBLE in the durable per-chunk // observability, with both flaggers naming themselves. func TestRegressionGuardFiresLiveWhenEnabled(t *testing.T) { n, detail := regressionStyleFlags(t, "\ngates:\n regression_guard:\n enabled: true\n") if n == 0 { t.Fatalf("regression guard enabled but the chunk records 0 style flags — the gate is wired to nothing") } for _, want := range []string{`"length_collapse":1`, `"number_drift":2`} { if !strings.Contains(detail, want) { t.Errorf("style detail does not carry %s:\n%s", want, detail) } } // The human-readable lines must survive to the detail too — a count with no sentence is not // observability, it is a number nobody can action. for _, want := range []string{"length collapse", "44", "128"} { if !strings.Contains(detail, want) { t.Errorf("style detail is missing %q:\n%s", want, detail) } } if n != 3 { // 1 collapse + 2 disappeared numbers t.Errorf("style flags = %d, want 3 (1 length collapse + 2 number drifts); detail: %s", n, detail) } } // TestRegressionGuardSilentWhenDisabled is the other half of the same wiring claim: the gate is OPT-IN, // so the identical run with no gates block must record nothing. Without this, a test asserting only the // firing side would pass just as happily if the guard ran unconditionally — and the "byte-identical when // off" property (cheapgates.go) would be unpinned. func TestRegressionGuardSilentWhenDisabled(t *testing.T) { n, detail := regressionStyleFlags(t, "") if n != 0 { t.Fatalf("regression guard is opt-in but a run without it recorded %d style flags: %s", n, detail) } } // TestRegressionGuardComparesDraftAgainstFinal pins WHICH two texts the edit wave hands the guard. The // gate is only meaningful over the draft→final transform; a wiring that passed the final twice (the shape // the draft-only wave legitimately uses) would silently report a clean book forever, because a text never // collapses against itself. Asserted by making the editor return the draft UNCHANGED: same content, so a // correct wiring is silent — and then the fixture above, which differs only in the editor's answer, // proves the channel is not simply dead. func TestRegressionGuardComparesDraftAgainstFinal(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, func(body string) (string, string) { return rgDraft, "stop" }) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{ source: suzukiSource, glossarySeed: suzukiSeed, gatesYAML: "\ngates:\n regression_guard:\n enabled: true\n", regenerate: 0, }) r := newRunner(t, bookPath) defer r.Close() if _, err := r.TranslateBook(obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})); err != nil { t.Fatal(err) } rs, err := r.Store.GetRetrievalState("test-book", 1, 0) if err != nil || rs == nil { t.Fatalf("retrieval_state: %v %v", rs, err) } if rs.NStyleFlags != 0 { t.Fatalf("an editor that returned the draft unchanged must not trip the guard, got %d flags: %s", rs.NStyleFlags, rs.StyleDetail) } }