textmachine/backend/internal/pipeline/regressionguard_live_test.go

139 lines
6.7 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 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)
}
}