From 74ffe4f0a54c979849865819779a7572c377f15e Mon Sep 17 00:00:00 2001 From: heaven Date: Sat, 5 Sep 2026 03:20:37 +0300 Subject: [PATCH] Close the loop's own false pins and the twins of the money hole: a preflight pinned by a list is not pinned, and a catalogue entry can rot three ways rather than one --- backend/cmd/tmmutate/main.go | 76 +++++- backend/cmd/tmmutate/mutations.json | 230 +++++++++++++++++- backend/internal/config/bank_contour_test.go | 90 +++++++ backend/internal/membank/render_test.go | 41 ++++ .../internal/pipeline/classifyinput_test.go | 113 ++++++++- .../pipeline/contourpreflight_test.go | 81 ++++++ .../internal/pipeline/flagseverity_test.go | 79 +++++- .../pipeline/offtarget_corpus_test.go | 18 +- backend/internal/pipeline/volumeslot_test.go | 139 +++++++++++ 9 files changed, 828 insertions(+), 39 deletions(-) diff --git a/backend/cmd/tmmutate/main.go b/backend/cmd/tmmutate/main.go index d9ef86ca..330cdd4d 100644 --- a/backend/cmd/tmmutate/main.go +++ b/backend/cmd/tmmutate/main.go @@ -155,7 +155,15 @@ func run(root, catalog, ids string, timeout time.Duration, logs string) error { // before each mutation. Belt and braces on purpose: the per-mutation restore verifies itself too, but // a restore that verifies against the wrong baseline verifies nothing — which is exactly how this // tool failed the first time it was used. + // + // ⚠ A FILE THIS PASS CANNOT READ IS THAT ENTRY'S ROT, NOT THE RUN'S DEATH. It used to `return` here, + // and that quietly undid the whole point of making a moved ANCHOR per-entry: a renamed or deleted file + // is the commonest way a catalogue goes stale, and one of them stopped the run BEFORE entry one — + // taking every healthy entry behind it down unseen, `-id` runs included, because this loop walks the + // whole catalogue and not the selection. Measured: a catalogue with one such entry printed + // «fingerprint …: no such file or directory» and ran nothing at all. baseline := map[string]string{} + rottedFile := map[string]string{} // mutation id → why its file could not be fingerprinted for _, m := range all { for _, e := range m.Edits { path := filepath.Join(root, e.File) @@ -164,7 +172,11 @@ func run(root, catalog, ids string, timeout time.Duration, logs string) error { } body, rerr := os.ReadFile(path) if rerr != nil { - return fmt.Errorf("fingerprint %s: %w", path, rerr) + // Recorded against the ENTRY, so the run reports it in place and still ends non-zero. The + // file is deliberately left out of the baseline: nothing may be measured against a file + // this pass never saw. + rottedFile[m.ID] = fmt.Sprintf("%s: %v (the file moved or was deleted under the catalogue)", m.ID, rerr) + continue } baseline[path] = hash(body) } @@ -190,6 +202,7 @@ func run(root, catalog, ids string, timeout time.Duration, logs string) error { // source is byte-identical afterwards) was reported RED and the gate exited 0. A baseline is what // makes a later red ATTRIBUTABLE. green := map[string]bool{} + rottedPkg := map[string]string{} for _, m := range all { if len(want) > 0 && !want[m.ID] { continue @@ -201,6 +214,16 @@ func run(root, catalog, ids string, timeout time.Duration, logs string) error { base := exec.Command("go", "test", m.Package, "-count=1", fmt.Sprintf("-timeout=%s", timeout)) base.Dir = root if out, berr := base.CombinedOutput(); berr != nil { + // ⚠ A PACKAGE PATH THAT NO LONGER RESOLVES IS ROT, and it is the third way a catalogue goes + // stale after a moved anchor and a moved file. It used to `return` here, so one renamed package + // stopped the run before entry one and took every healthy entry with it — the same shape the + // other two were fixed out of. A package that resolves and is genuinely RED still stops the + // run: a verdict measured against a package that was already failing is unattributable, which + // is the whole reason this baseline exists. + if setupFailed(out) { + rottedPkg[m.Package] = fmt.Sprintf("package %s does not resolve (it was renamed, moved or deleted under the catalogue)", m.Package) + continue + } return fmt.Errorf("%s is NOT GREEN before any mutation is planted — every verdict about it would be unattributable:\n%s", m.Package, tailOf(out)) } @@ -212,6 +235,19 @@ func run(root, catalog, ids string, timeout time.Duration, logs string) error { if len(want) > 0 && !want[m.ID] { continue } + if why, dead := rottedPkg[m.Package]; dead { + ran++ + fmt.Printf("ROTTED %-28s %s\n", m.ID, why) + survived = append(survived, m.ID+" (package moved)") + continue + } + if why, dead := rottedFile[m.ID]; dead { + // Reported in place, before anything is planted: the entry guards nothing, and the run goes on. + ran++ + fmt.Printf("ROTTED %-28s %s\n", m.ID, why) + survived = append(survived, m.ID+" (file moved)") + continue + } if err := pristine("before " + m.ID); err != nil { return err } @@ -226,7 +262,10 @@ func run(root, catalog, ids string, timeout time.Duration, logs string) error { // Counted as an unexpected outcome, exactly like NOTHING: an entry whose anchor moved guards // nothing, and the run must still end non-zero. What it no longer does is stop the run. fmt.Printf("ROTTED %-28s %s\n", m.ID, detail) - survived = append(survived, m.ID+" (anchor moved)") + // The summary label stays generic: the DETAIL line above already says which kind of rot this + // was, and a label naming one kind for all of them would be the same over-claim in miniature — + // «anchor moved» printed for an entry whose run filter had been renamed. + survived = append(survived, m.ID+" (rotted)") case verdict == inconclusive: // NOT a red. The package did not build, or the run died before a single test reported — so // nothing was judged and the entry proves nothing. Counted as an unexpected outcome on @@ -374,13 +413,12 @@ func one(root string, m Mutation, timeout time.Duration, logs string) (v verdict path := filepath.Join(root, file) body, rerr := os.ReadFile(path) if rerr != nil { - // ROT, not a run failure. ⚠ AND IT DOES NOT CLOSE THE MOVED-FILE CASE, which is what a first - // draft of this comment claimed: the fingerprint pass above reads every file the catalogue names - // BEFORE any entry runs, so a renamed or deleted file still aborts the whole run there, ahead of - // this line. Measured: a catalogue with one entry naming a missing file printed - // «fingerprint …: no such file or directory» and ran nothing, the healthy entry behind it - // included. What this branch does cover is the narrow race — a file that disappears BETWEEN the - // fingerprint pass and this read. The moved-file abort is a live gap with a backlog row. + // ROT, not a run failure. The moved-file case itself is caught EARLIER, by the fingerprint pass, + // which records it against the entry and lets the run continue; what this branch covers is the + // narrow race in between — a file that disappears after that pass and before this read. + // ⚠ An earlier version of this comment said the moved-file case "still aborts the whole run". + // That was true when it was written and false one edit later, and it is the exact class of + // defect this tool exists to find: a sentence that outlived the code it described. return rotted, fmt.Sprintf("%s: %v (the file moved or was deleted under the catalogue)", m.ID, rerr), nil } mu.Lock() @@ -420,6 +458,15 @@ func one(root string, m Mutation, timeout time.Duration, logs string) (v verdict return survivedTests, "", fmt.Errorf("%s: write the log: %w", m.ID, werr) } } + // ⚠ A `run` FILTER THAT MATCHES NOTHING IS ROT, NOT A SURVIVOR. `go test -run TestRenamedAway` runs + // zero tests and exits 0, so a renamed pin turned every entry that names it green forever: an + // expect-RED entry printed SURVIVED (a finding that is really a NOTHING — no test was judged), and an + // expect-`survives` entry printed exactly what the catalogue records and kept the whole gate at exit 0. + // The catalogue has entries with run filters precisely so a red is attributable to ONE pin, which is + // what makes this failure quiet: the narrower the filter, the more a rename costs. + if strings.Contains(string(out), "no tests to run") { + return rotted, fmt.Sprintf("%s: the run filter %q matched no test — it was renamed or removed, and this entry has been asserting nothing", m.ID, m.Run), nil + } if terr == nil { return survivedTests, "", nil } @@ -445,3 +492,14 @@ func hash(b []byte) string { sum := sha256.Sum256(b) return hex.EncodeToString(sum[:]) } + +// setupFailed reports whether `go test` refused the PACKAGE PATTERN itself rather than running anything — +// the package was renamed, moved or deleted under the catalogue. Distinguishable from a genuine red in the +// output, and it must be, because the two get opposite treatment: rot is recorded per entry and the run +// goes on, a real red stops everything. +func setupFailed(out []byte) bool { + s := string(out) + return strings.Contains(s, "[setup failed]") || + strings.Contains(s, "directory not found") || + strings.Contains(s, "matched no packages") +} diff --git a/backend/cmd/tmmutate/mutations.json b/backend/cmd/tmmutate/mutations.json index b6271b29..2f37eca5 100644 --- a/backend/cmd/tmmutate/mutations.json +++ b/backend/cmd/tmmutate/mutations.json @@ -1322,7 +1322,7 @@ }, { "id": "WB15-screen-floor-slides-inside-its-interval", - "why": "the threshold is ratified as a CONTRACT, not a tuning knob (D39.92 п.1а), and it was pinned only to an INTERVAL — 0.45 is still inside the corpus's empty band, so the slide passed everything AND moved no version, which would silently re-verdict a resumed chunk under a threshold nobody re-billed for. langscreen.Version is now derived from the constants, so the snapshot moves by construction", + "why": "a threshold ratified as a CONTRACT (D39.92 п.1а) was pinned only to an INTERVAL — 0.45 is still inside the corpus's empty band — so a slide re-verdicted a resumed chunk under a threshold nobody re-billed for. Version is derived from the constants, so THIS edit moves the snapshot and the golden catches it. ⚠ THAT IS THE WHOLE OF WHAT THIS ENTRY PROVES: the golden sees a slide, and it CANNOT see a literal that stops tracking the constants — under `literal + slide` the string is byte-identical, the golden does not move and this entry survives. The derivation itself is WB36's, in the langscreen package. An earlier `why` here claimed «the snapshot moves by construction» and read as a guarantee against both", "package": "./internal/pipeline/", "run": "TestGoldenDeterminism", "edits": [ @@ -1763,5 +1763,233 @@ "replace": "\t\t\tsignedSrc[p.entry.src] = true" } ] + }, + { + "id": "WB51-flag-reason-declared-elsewhere", + "why": "every FlagReason the engine declares must be RANKED; the exhaustiveness test used to read one file, so a reason declared anywhere else fell to the unknown bucket while the test still said «every declared reason is ranked»", + "package": "./internal/pipeline/", + "edits": [ + { + "file": "internal/pipeline/status.go", + "find": "const severityUnknown = 8\n", + "replace": "const severityUnknown = 8\n\n// planted\nconst FlagPlantedElsewhere FlagReason = \"planted_elsewhere\"\n" + } + ] + }, + { + "id": "WB52-builder-hardcodes-the-target", + "why": "the classify input's target must come from the BOOK; a hard-coded target makes every book judged as the shipping pair, and no runner fixture translates into anything but Russian, so nothing else can see it", + "package": "./internal/pipeline/", + "edits": [ + { + "file": "internal/pipeline/chunkrun.go", + "find": "\t\tTargetLang: r.Book.TargetLang,\n", + "replace": "\t\tTargetLang: \"ru\",\n" + } + ] + }, + { + "id": "WB53-builder-hardcodes-target-scripts", + "why": "the target's scripts must come from the BOOK's target: hard-coded, a →ja book is screened against Cyrillic and every correct chapter is off-target", + "package": "./internal/pipeline/", + "edits": [ + { + "file": "internal/pipeline/chunkrun.go", + "find": "\t\tTargetScripts: lang.LangScripts(r.Book.TargetLang),\n", + "replace": "\t\tTargetScripts: lang.LangScripts(\"ru\"),\n" + } + ] + }, + { + "id": "WB54-builder-hardcodes-source-scripts", + "why": "the source's scripts must come from the RUN's compiled checkers: hard-coded to zh, the echo rule measures the wrong script for every other source language", + "package": "./internal/pipeline/", + "edits": [ + { + "file": "internal/pipeline/chunkrun.go", + "find": "\t\tSourceScripts: r.checkers.SourceScripts(),\n", + "replace": "\t\tSourceScripts: lang.LangScripts(\"zh\"),\n" + } + ] + }, + { + "id": "WB56-labelled-preflight-skips-gates", + "why": "the LABELLED branch of CheckKeys must demand the keys of the models the gates call: it was pinned only by the reachable LIST, which is exactly the thing «a pin on the list is not a pin on the preflight» rejects", + "package": "./internal/config/", + "edits": [ + { + "file": "internal/config/models.go", + "find": "\t\tfor _, mdl := range pipe.ReachableModels() {\n\t\t\tneeded[mdl] = struct{}{}\n\t\t}\n\t\treturn m.checkKeysFor(needed)", + "replace": "\t\tfor _, st := range pipe.Stages {\n\t\t\tneeded[st.ResolvedModel] = struct{}{}\n\t\t}\n\t\treturn m.checkKeysFor(needed)" + } + ] + }, + { + "id": "WB57-banknote-warning-loses-the-pack-arm", + "why": "the banknote warning fires when EITHER mining input is missing; keyed on the contrast artifact alone, a book with a pack and no contrast pays for a term table nobody reads and is told nothing", + "package": "./internal/pipeline/", + "edits": [ + { + "file": "internal/pipeline/runner.go", + "find": "(r.pack == nil || r.Pipeline.Mining.ContrastPath == \"\")", + "replace": "(r.Pipeline.Mining.ContrastPath == \"\")" + } + ] + }, + { + "id": "WB58-banknote-warning-loses-the-contrast-arm", + "why": "the mirror arm: keyed on the pack alone, a book with a contrast artifact and no langpack pays for the same unread table in silence", + "package": "./internal/pipeline/", + "edits": [ + { + "file": "internal/pipeline/runner.go", + "find": "(r.pack == nil || r.Pipeline.Mining.ContrastPath == \"\")", + "replace": "(r.pack == nil)" + } + ] + }, + { + "id": "WB59-banknote-warning-fires-on-a-read-open", + "why": "a $0 read path buys nothing, so warning there teaches an operator to ignore the line on the run where it costs money", + "package": "./internal/pipeline/", + "edits": [ + { + "file": "internal/pipeline/runner.go", + "find": "\tif forWrite && r.Pipeline.Gates.Banknote.Enabled && (", + "replace": "\tif r.Pipeline.Gates.Banknote.Enabled && (" + } + ] + }, + { + "id": "WB60-volume-report-decided-after-reconcile", + "why": "the volume report is decided BEFORE reconcile trues the counters up: decided after, a run whose only work outside its grant was a carried unit that came back FLAGGED has Carried==0 and reports nothing at all — the one run that most needs the disclosure goes silent. ⚠ Run-filtered: this planting also strips the report from the DRAFT-ONLY path, which three neighbouring tests catch for a different reason (they lose the report entirely, not out of order). Unfiltered it would go red without proving anything about this pin. WB64 is the faithful both-paths form", + "package": "./internal/pipeline/", + "edits": [ + { + "file": "internal/pipeline/waverun.go", + "find": "\tif scope.bound() {\n\t\tres.Volume = &scope.stop\n", + "replace": "\tif scope.bound() {\n" + }, + { + "file": "internal/pipeline/waverun.go", + "find": "\tscope.reconcile(res.Chunks)\n\tr.Log.InfoContext(ctx, \"book run finished\", \"book\"", + "replace": "\tscope.reconcile(res.Chunks)\n\tif scope.bound() {\n\t\tres.Volume = &scope.stop\n\t}\n\tr.Log.InfoContext(ctx, \"book run finished\", \"book\"" + } + ], + "run": "TestTheVolumeReportIsAskedBeforeTheCountersAreTruedUp" + }, + { + "id": "WB55-corpus-provenance-swapped", + "why": "the corpus digest must cover the ADDRESS a specimen was taken from, not only its counts: a corpus whose numbers are right and whose provenance is wrong is unreproducible in the one way that matters", + "package": "./internal/pipeline/", + "edits": [ + { + "file": "internal/pipeline/testdata/offtarget-corpus.json", + "find": "\"provenance\": \"probe-4axes/arms/backups/20260902T075308Z.db#draft\",\n \"head\": \"Рассечение камня", + "replace": "\"provenance\": \"planted/not-the-address-it-came-from.db#draft\",\n \"head\": \"Рассечение камня" + } + ] + }, + { + "id": "WB61-builder-hardcodes-the-fixtures-own-pair", + "why": "a hard-code to the FIXTURE'S OWN pair is the hole a value-comparing test cannot see: the earlier pin asserted the builder's output against values chosen for one book, so it caught a hard-code to the shipping pair and would have sailed past a hard-code to the pair the test itself uses. Only «two books must answer differently» is unsatisfiable by any constant. ⚠ Run-filtered on purpose: a hard-code to `ja` also breaks twenty ru fixtures, so an unfiltered entry would go red without proving anything about THIS pin. The dangerous hard-code is the one to the SHIPPING pair (WB52), which nothing else sees", + "package": "./internal/pipeline/", + "edits": [ + { + "file": "internal/pipeline/chunkrun.go", + "find": "\t\tTargetLang: r.Book.TargetLang,\n", + "replace": "\t\tTargetLang: \"ja\",\n" + } + ], + "run": "TestClassifyInputForLeavesNoAxisUnset" + }, + { + "id": "WB62-innocent-const-must-not-be-accused", + "expect": "survives", + "why": "THE BOUNDARY OF TestEveryFlagReasonIsRanked, recorded because the test used to cross it. An ordinary untyped string constant sitting in the FlagReason block is NOT a flag — Go types it as untyped string, inheriting nothing — and the walk must ignore it. An earlier version carried the previous spec's type forward and failed the test with a message accusing a documentation URL of being an unranked flag. A test that accuses the innocent is not enforcing its own sentence, so this planting MUST survive", + "package": "./internal/pipeline/", + "run": "TestEveryFlagReasonIsRanked", + "edits": [ + { + "file": "internal/pipeline/disposition.go", + "find": "\tFlagSanitizerStripped FlagReason = \"sanitizer_stripped\"\n", + "replace": "\tFlagSanitizerStripped FlagReason = \"sanitizer_stripped\"\n\n\t// not a flag: a documentation pointer that happens to live in this block\n\tflagReasonDocURL = \"https://example.invalid/flags\"\n" + } + ] + }, + { + "id": "WB63-run-compiles-a-constant-source", + "why": "the run's declared SOURCE scripts must come from the book. This is the WRITER of the axis whose reader was pinned one commit earlier — pinning the reader alone left the whole axis decided by an unguarded line, and a constant here gives a ko→ru or ja→ru book the wrong declared source while the echo detector measures a script the book does not use", + "package": "./internal/pipeline/", + "run": "TestTheRunCompilesTheBooksOwnSourceScripts", + "edits": [ + { + "file": "internal/pipeline/runner.go", + "find": "\t\tr.checkers.SetSourceScripts(lang.LangScripts(r.Book.SourceLang))\n", + "replace": "\t\tr.checkers.SetSourceScripts(lang.LangScripts(\"ja\"))\n" + } + ] + }, + { + "id": "WB64-volume-report-after-reconcile-on-both-paths", + "why": "the FAITHFUL form of the ordering defect: the decision moved after reconcile on BOTH the draft-only and the editor path, so no neighbour loses its report for an unrelated reason. Only a fixture whose single unit is carried AND flagged can tell the two orders apart, and until this entry existed the draft-only path had none", + "package": "./internal/pipeline/", + "run": "TestTheVolumeReportIsAskedBefore", + "edits": [ + { + "file": "internal/pipeline/waverun.go", + "find": "\tif scope.bound() {\n\t\tres.Volume = &scope.stop\n", + "replace": "\tif scope.bound() {\n" + }, + { + "file": "internal/pipeline/waverun.go", + "find": "\t\tscope.reconcile(res.Chunks)\n\t\tr.Log.InfoContext(ctx, \"book run finished (draft-only)\"", + "replace": "\t\tscope.reconcile(res.Chunks)\n\t\tif scope.bound() {\n\t\t\tres.Volume = &scope.stop\n\t\t}\n\t\tr.Log.InfoContext(ctx, \"book run finished (draft-only)\"" + }, + { + "file": "internal/pipeline/waverun.go", + "find": "\tscope.reconcile(res.Chunks)\n\tr.Log.InfoContext(ctx, \"book run finished\", \"book\"", + "replace": "\tscope.reconcile(res.Chunks)\n\tif scope.bound() {\n\t\tres.Volume = &scope.stop\n\t}\n\tr.Log.InfoContext(ctx, \"book run finished\", \"book\"" + } + ] + }, + { + "id": "WB65-labelled-preflight-skips-the-hop", + "why": "a labelled run CALLS its escalation hop, and BuildClient never refuses an empty key — it only becomes a header. A preflight blind to the hop lets the book open, buy the draft wave and 401 at the escalation: the same late failure as the gates, one field over", + "package": "./internal/config/", + "run": "TestTheLABELLEDPreflightDemandsEveryCallersKeyToo", + "edits": [ + { + "file": "internal/config/pipeline.go", + "find": "\t\tadd(st.ResolvedHop)\n", + "replace": "" + } + ] + }, + { + "id": "WB66-preflight-demands-the-configured-not-the-resolved-model", + "why": "routing is what a labelled book is FOR: the model in the yaml may not be the model that answers. A preflight that demands the CONFIGURED model passes a book whose RESOLVED model has no key, and the refusal moves from open time to the first draft call", + "package": "./internal/config/", + "run": "TestTheLABELLEDPreflightDemandsEveryCallersKeyToo", + "edits": [ + { + "file": "internal/config/pipeline.go", + "find": "\t\tadd(st.ResolvedModel)\n", + "replace": "\t\tadd(st.Model)\n" + } + ] + }, + { + "id": "WB67-flag-reason-in-the-conversion-spelling", + "why": "`X = FlagReason(\"…\")` is as ordinary a declaration as `X FlagReason = \"…\"`, and a walk that reads only the spec's type is blind to it — exhibited as a pair, the typed spelling caught and the conversion spelling not", + "package": "./internal/pipeline/", + "run": "TestEveryFlagReasonIsRanked", + "edits": [ + { + "file": "internal/pipeline/status.go", + "find": "const severityUnknown = 8\n", + "replace": "const severityUnknown = 8\n\n// planted\nconst FlagPlantedConv = FlagReason(\"planted_conv\")\n" + } + ] } ] \ No newline at end of file diff --git a/backend/internal/config/bank_contour_test.go b/backend/internal/config/bank_contour_test.go index 3cbbe14a..0bbbb378 100644 --- a/backend/internal/config/bank_contour_test.go +++ b/backend/internal/config/bank_contour_test.go @@ -259,3 +259,93 @@ func TestTheUnlabelledPreflightDemandsEveryCallersKey(t *testing.T) { }) } } + +// TestTheLABELLEDPreflightDemandsEveryCallersKeyToo is the twin of the test above, and it exists because +// the fix that closed the hole closed ONE branch of it. +// +// ⚠ CheckKeys forks on whether the book carries content labels. The unlabelled branch — the shipping +// default — was the one found open and the one pinned. The labelled branch reaches the gates only through +// ReachableModels(), and the only thing asserting that was TestABankModelIsAReachableModel, which reads +// the LIST. «A pin on the list is not a pin on the preflight» is the sentence this pack was written around; +// leaving the second branch pinned by exactly the thing that sentence rejects is the same defect with the +// paint changed. Planted — the labelled branch collecting stage models by hand — the whole package stayed +// green, and a labelled book with its bank role on a keyless provider bought the draft wave and died at +// the gate, which is where this started. +func TestTheLABELLEDPreflightDemandsEveryCallersKeyToo(t *testing.T) { + m := &Models{ + Providers: map[string]Provider{ + "stage-provider": {Kind: "openai", BaseURL: "http://x", APIKeyEnv: "TEST_STAGE_KEY"}, + "other-provider": {Kind: "openai", BaseURL: "http://y", APIKeyEnv: "TEST_OTHER_KEY"}, + }, + Models: map[string]Model{ + "draft-model": {Provider: "stage-provider"}, + "bank-model": {Provider: "other-provider"}, + "classify-model": {Provider: "other-provider"}, + "repair-model": {Provider: "other-provider"}, + "hop-model": {Provider: "other-provider"}, + }, + } + t.Setenv("TEST_STAGE_KEY", "sk-present") + t.Setenv("TEST_OTHER_KEY", "") + + base := func() *Pipeline { + return &Pipeline{ + ContentLabels: []string{"violence"}, + Stages: []Stage{{Name: "draft", Model: "draft-model", ResolvedModel: "draft-model"}}, + } + } + for _, c := range []struct { + name string + mut func(*Pipeline) + }{ + {"the terminologist", func(p *Pipeline) { + p.Gates.Terminology = TerminologyGate{Enabled: true, Model: "bank-model"} + }}, + {"the classifier phase", func(p *Pipeline) { + p.Gates.Terminology = TerminologyGate{Enabled: true, Model: "draft-model", + ClassifyTypes: true, ClassifyModel: "classify-model"} + }}, + {"the repair gate", func(p *Pipeline) { + p.Gates.Repair = RepairGate{Enabled: true, Model: "repair-model"} + }}, + // ⚠ THE HOP, found by an adversarial pass over this very test: the gates were covered and the + // stage's own hop was not. A labelled run calls its escalation hop, BuildClient never refuses an + // empty key (it only becomes a header), so under a preflight blind to the hop the book opens, buys + // the draft wave, and 401s at the escalation — the same late failure, one field over. + {"the stage's escalation hop", func(p *Pipeline) { + p.Stages[0].ResolvedHop = "hop-model" + }}, + // ⚠ AND THE RESOLVED MODEL, NOT THE CONFIGURED ONE. Routing is what a labelled book is FOR: the + // model in the yaml may not be the model that answers. A preflight that demanded `Model` would pass + // here and the first draft call would 401 — the refusal moving from open time to mid-run, which is + // precisely the distance this whole family of tests exists to keep at zero. + {"the RESOLVED model when routing replaced the configured one", func(p *Pipeline) { + p.Stages[0].Model, p.Stages[0].ResolvedModel = "draft-model", "bank-model" + }}, + } { + t.Run(c.name, func(t *testing.T) { + p := base() + c.mut(p) + if len(p.ContentLabels) == 0 { + t.Fatal("premise broken: this test is about the LABELLED branch and must carry labels") + } + err := m.CheckKeys(p) + if err == nil { + t.Fatalf("a LABELLED book's %s calls a provider whose key is absent and the preflight passed: "+ + "the run buys the draft wave and dies at the gate", c.name) + } + if !strings.Contains(err.Error(), "TEST_OTHER_KEY") { + t.Fatalf("the refusal must name the env var an operator has to set, got: %v", err) + } + }) + } + // And the disabled direction, so this cannot be satisfied by demanding every model in the file. + t.Run("gates off demand nothing", func(t *testing.T) { + p := base() + p.Gates.Terminology = TerminologyGate{Enabled: false, Model: "bank-model"} + p.Gates.Repair = RepairGate{Enabled: false, Model: "repair-model"} + if err := m.CheckKeys(p); err != nil { + t.Fatalf("a labelled book that never calls those models must not be made to hold their keys: %v", err) + } + }) +} diff --git a/backend/internal/membank/render_test.go b/backend/internal/membank/render_test.go index e0158078..9f96f4c8 100644 --- a/backend/internal/membank/render_test.go +++ b/backend/internal/membank/render_test.go @@ -297,6 +297,47 @@ func TestTheLawBlockGivesOneOrderPerSourceTerm(t *testing.T) { if n := strings.Count(RenderEditorConstraintBlock(sortByPriority(padded), tx), "元海 → "); n != 1 { t.Errorf("the signed set and the lookup must normalize the surface the same way, got %d lines", n) } + + // (10) ⚠ TWO UNSIGNED ROWS OF ONE SURFACE BOTH STAND — and this is a DECLARED CHANGE OF WIRE BEHAVIOUR, + // written down here because it happened and was not declared when it did. + // + // The rule this block enforces used to dedup on the bare `src`, which collapsed any two renderings of a + // surface to whichever the sort put first. That was corrected because it dropped a second SIGNED canon + // (青山 the surname and 青山 the direction are two decisions the owner made separately). The correction + // keys on (src, dst) and makes only an UNSIGNED row yield, and only to a SIGNED one — so where there is + // no signature at all, nothing yields and both renderings reach the model. Measured across the change: + // the same two unsigned rows produced ONE line before and TWO after. + // + // ⚠ WHAT THIS CASE DOES AND DOES NOT SAY. It pins the RENDER's behaviour: with no signature present + // there is nothing to prefer, so the block does not arbitrate — it shows both and lets the run's own + // unsigned-conflict report (TestUnsignedConflictIsReportedByTheRun) be the place a human is told. A + // silent pick-by-sort-order would be the engine choosing between two of its own guesses and hiding + // that it did, which is strictly worse at THIS layer. + // + // It does NOT say that two unsigned renderings of one surface may exist in the first place. That is an + // UPSTREAM question and the owner has answered it: an unsigned term must have ONE rendering too, and + // the consolidation stage owns that, not the signature. Measured today, the bank accepts the state — + // two rows, one src, one (empty) sense, overlapping spoiler windows, different dst — because the + // same-sense overlap guard (memseed.go) runs PER FILE and the merge (storeOrder) refuses only an exact + // duplicate of the UNIQUE key, so a seed row and a mined-delta row collide only after both have passed + // their own loader. When that is fixed upstream this state stops being reachable and this case becomes + // a statement about something that no longer occurs — which is the right time to delete it, and the + // wrong time to read it as doctrine that two lines are DESIRABLE. + unsignedPair := []PickedEntry{ + {entry: &entry{src: "元石", dst: "юаньши", status: "draft"}, Via: "元石", Disp: Ambiguous, KeyTrusted: true}, + {entry: &entry{src: "元石", dst: "камень первоисточника", status: "auto"}, Via: "元石", Disp: Ambiguous, KeyTrusted: true}, + } + got10 := RenderEditorConstraintBlock(sortByPriority(unsignedPair), tx) + if n := strings.Count(got10, "元石 → "); n != 2 { + t.Errorf("two UNSIGNED renderings of one surface must BOTH reach the model — there is no signature "+ + "for either to yield to, and picking one by sort order hides a disagreement the run reports. "+ + "Got %d lines:\n%s", n, got10) + } + // And the draft wire must say the same thing: the two wires disagreeing about how many renderings a + // surface has is the shape the original regression took. + if draft := RenderGlossaryBlock(sortByPriority(unsignedPair), tx); strings.Count(draft, "元石 → ") != 2 { + t.Errorf("the two wires disagree about the unsigned pair:\neditor: %s\ndraft: %s", got10, draft) + } } // TestTheBudgetKeepsTheTrustworthyMatchFirst is where priorityRank's DISPOSITION axis actually decides diff --git a/backend/internal/pipeline/classifyinput_test.go b/backend/internal/pipeline/classifyinput_test.go index b95b4468..2c9c184f 100644 --- a/backend/internal/pipeline/classifyinput_test.go +++ b/backend/internal/pipeline/classifyinput_test.go @@ -1,8 +1,11 @@ package pipeline import ( + "os" "reflect" + "strings" "testing" + "unicode" "textmachine/backend/internal/checks" "textmachine/backend/internal/config" @@ -24,9 +27,22 @@ import ( // the same person who forgets to update the builder, which is the failure it is supposed to prevent. A new // axis added to classifyInput and not wired in fails here on the day it is declared. func TestClassifyInputForLeavesNoAxisUnset(t *testing.T) { - ck := checks.CompileCheckersFor(nil, lang.TargetChecksFor("ru")) - ck.SetSourceScripts(lang.LangScripts("zh")) - r := &Runner{Book: &config.Book{TargetLang: "ru"}, checkers: ck} + // ⚠ A NON-ru TARGET AND A NON-zh SOURCE, AND THAT IS THE WHOLE DESIGN OF THIS FIXTURE. The first + // version used ru/zh — the shipping pair — so a builder that ignored the book entirely and hard-coded + // `lang.LangScripts("ru")` produced exactly the values asserted, and three separate plantings that did + // precisely that survived the whole package. Nothing else in the battery could catch them either: + // every runner fixture translates INTO Russian. A pair the fixture does not share with the hard-code + // is the only shape of this test that can fail. + const target, source = "ja", "ko" + if len(lang.LangScripts(target)) == len(lang.LangScripts("ru")) || len(lang.LangScripts(source)) == len(lang.LangScripts("zh")) { + t.Fatalf("premise broken: this test can only catch a hard-coded ru/zh builder while the script COUNTS "+ + "differ — %s has %d, ru has %d; %s has %d, zh has %d. Pick another pair or assert by identity.", + target, len(lang.LangScripts(target)), len(lang.LangScripts("ru")), + source, len(lang.LangScripts(source)), len(lang.LangScripts("zh"))) + } + ck := checks.CompileCheckersFor(nil, lang.TargetChecksFor(target)) + ck.SetSourceScripts(lang.LangScripts(source)) + r := &Runner{Book: &config.Book{TargetLang: target}, checkers: ck} // Every argument is chosen NON-ZERO, so that any zero field in the result is an unwired axis and not an // artefact of the fixture. @@ -41,16 +57,16 @@ func TestClassifyInputForLeavesNoAxisUnset(t *testing.T) { } } // The two axes the incident was actually about, asserted by value and not merely as non-zero. - if in.TargetLang != "ru" { - t.Errorf("TargetLang = %q, want the book's", in.TargetLang) + if in.TargetLang != target { + t.Errorf("TargetLang = %q, want the book's %q", in.TargetLang, target) } - if len(in.TargetScripts) != len(lang.LangScripts("ru")) { - t.Errorf("TargetScripts must come from the book's target: got %d tables, want %d", - len(in.TargetScripts), len(lang.LangScripts("ru"))) + if len(in.TargetScripts) != len(lang.LangScripts(target)) { + t.Errorf("TargetScripts must come from the BOOK's target, not a constant: got %d tables, want %d for %s", + len(in.TargetScripts), len(lang.LangScripts(target)), target) } - if len(in.SourceScripts) != len(lang.LangScripts("zh")) { - t.Errorf("SourceScripts must come from the run's compiled checkers: got %d tables, want %d", - len(in.SourceScripts), len(lang.LangScripts("zh"))) + if len(in.SourceScripts) != len(lang.LangScripts(source)) { + t.Errorf("SourceScripts must come from the RUN's compiled checkers, not a constant: got %d tables, want %d for %s", + len(in.SourceScripts), len(lang.LangScripts(source)), source) } if !in.NonProseReply { t.Error("NonProseReply must pass through: a bank role's reply is not prose and must not be echo-screened") @@ -59,4 +75,79 @@ func TestClassifyInputForLeavesNoAxisUnset(t *testing.T) { if r.classifyInputFor("源文本", "перевод", llm.FinishStop, false).NonProseReply { t.Error("NonProseReply is pinned to true inside the builder — the prose path would be exempted from the echo rule") } + + // ⛔ TWO BOOKS, AND THIS IS THE ASSERTION A CONSTANT CANNOT SATISFY. Everything above compares the + // builder's output against values chosen for THIS book, so it catches a hard-code to the shipping pair + // and nothing else — a hard-code to ja/ko would sail through the very fixture written to catch + // hard-codes. Two books whose answers must DIFFER is the only shape that cannot be met by any constant, + // whichever one is picked. + other := checks.CompileCheckersFor(nil, lang.TargetChecksFor("ru")) + other.SetSourceScripts(lang.LangScripts("zh")) + r2 := &Runner{Book: &config.Book{TargetLang: "ru"}, checkers: other} + a := r.classifyInputFor("源文本", "перевод", llm.FinishStop, false) + b := r2.classifyInputFor("源文本", "перевод", llm.FinishStop, false) + if a.TargetLang == b.TargetLang { + t.Errorf("two books with different targets produced the same TargetLang %q — the builder is not "+ + "reading the book at all", a.TargetLang) + } + if len(a.TargetScripts) == len(b.TargetScripts) { + t.Errorf("two books with different targets produced the same TargetScripts (%d tables) — the builder "+ + "is not reading the book's target", len(a.TargetScripts)) + } + if len(a.SourceScripts) == len(b.SourceScripts) { + t.Errorf("two runs with different compiled checkers produced the same SourceScripts (%d tables) — the "+ + "builder is not reading the run's checkers", len(a.SourceScripts)) + } +} + +// TestTheRunCompilesTheBooksOwnSourceScripts pins the OTHER end of the same axis, one call site away from +// the builder above — and it was found by an adversarial pass over that very fix. +// +// ⚠ The builder reads r.checkers.SourceScripts(); loadLangPack is what FILLS them, from the book's +// SourceLang. Pinning the reader and not the writer leaves the whole axis decided by an unguarded line: a +// hard-code there gives a ko→ru or ja→ru book the wrong declared source, the echo detector then measures a +// script the book does not use, and nothing in the battery says a word. Same two-book shape as above, +// because the same reason applies — any constant makes two books agree. +func TestTheRunCompilesTheBooksOwnSourceScripts(t *testing.T) { + srv := newJSONProvider(&reqRec{}, draftEdit) + defer srv.Close() + + sourceScriptsFor := func(t *testing.T, sourceLang string) []*unicode.RangeTable { + t.Helper() + bookPath := setupProjectOpts(t, srv.URL, projectOpts{}) + if sourceLang != "ja" { + raw, err := os.ReadFile(bookPath) + if err != nil { + t.Fatal(err) + } + body := strings.Replace(string(raw), "source_lang: ja", "source_lang: "+sourceLang, 1) + if body == string(raw) { + t.Fatalf("premise broken: the fixture's book.yaml no longer carries `source_lang: ja`:\n%s", raw) + } + writeFile(t, bookPath, body) + } + r := newRunner(t, bookPath) + defer r.Close() + return r.checkers.SourceScripts() + } + + // ja declares three scripts, ko two — so a constant of either kind is visible as a disagreement. + if len(lang.LangScripts("ja")) == len(lang.LangScripts("ko")) { + t.Fatalf("premise broken: this test needs two sources whose script COUNTS differ, got ja=%d ko=%d", + len(lang.LangScripts("ja")), len(lang.LangScripts("ko"))) + } + ja := sourceScriptsFor(t, "ja") + ko := sourceScriptsFor(t, "ko") + + if len(ja) != len(lang.LangScripts("ja")) { + t.Errorf("a ja-source book compiled %d source scripts, want %d — the run is not reading the book's "+ + "source language, so the echo detector measures the wrong script", len(ja), len(lang.LangScripts("ja"))) + } + if len(ko) != len(lang.LangScripts("ko")) { + t.Errorf("a ko-source book compiled %d source scripts, want %d", len(ko), len(lang.LangScripts("ko"))) + } + if len(ja) == len(ko) { + t.Errorf("two books with different source languages compiled the SAME source scripts (%d tables) — "+ + "the run is using a constant, and no assertion about one book alone can see that", len(ja)) + } } diff --git a/backend/internal/pipeline/contourpreflight_test.go b/backend/internal/pipeline/contourpreflight_test.go index c3ece193..406f6d5a 100644 --- a/backend/internal/pipeline/contourpreflight_test.go +++ b/backend/internal/pipeline/contourpreflight_test.go @@ -198,3 +198,84 @@ func TestEveryPromptLoadPathRefusesAnUnrenderableTemplate(t *testing.T) { }) } } + +// TestTheBanknoteWarningKeysOnEachConditionSeparately closes a WB41-class hole in the test above: its +// positive fixture has NEITHER mining input and its negative has BOTH, so every one of the guard's parts +// could be deleted and the pair still passed. Planted separately, three of them survived the whole package — +// dropping the pack half of the disjunction, dropping the contrast half, and dropping `forWrite` entirely. +// +// The guard is `forWrite && banknote.Enabled && (pack == nil || contrast == "")`. A disjunction pinned only +// at "neither" and "both" is pinned at neither of its arms. +func TestTheBanknoteWarningKeysOnEachConditionSeparately(t *testing.T) { + packRoot, err := filepath.Abs(filepath.Join("..", "..", "configs", "langpacks")) + if err != nil { + t.Fatal(err) + } + open := func(t *testing.T, bookPath string, readOnly bool) string { + t.Helper() + var log bytes.Buffer + h := slog.New(slog.NewTextHandler(&log, &slog.HandlerOptions{Level: slog.LevelInfo})) + var r *Runner + var oerr error + if readOnly { + r, oerr = NewReadOnlyRunner(bookPath, h) + } else { + r, oerr = NewRunner(bookPath, h) + } + if oerr != nil { + t.Fatalf("this configuration is legal and must open — it is wasteful, not broken: %v", oerr) + } + r.Close() + return log.String() + } + + // ARM 1 — a pack, but no contrast artifact. The bank-mining stop returns before reading anything, so + // the banknote tokens are bought and unread; the pack half of the disjunction must not be what decides. + t.Run("a pack but no contrast artifact", func(t *testing.T) { + srv := newJSONProvider(&reqRec{}, draftEdit) + defer srv.Close() + bookPath := setupProjectOpts(t, srv.URL, projectOpts{banknote: true}) + raw, rerr := os.ReadFile(bookPath) + if rerr != nil { + t.Fatal(rerr) + } + body := strings.Replace(string(raw), "source_lang: ja", "source_lang: zh\nlangpack_root: "+packRoot, 1) + if !strings.Contains(body, "langpack_root:") { + t.Fatalf("premise broken: the fixture's book.yaml no longer carries the anchor this test edits:\n%s", raw) + } + writeFile(t, bookPath, body) + if out := open(t, bookPath, false); !strings.Contains(out, "banknote channel is ON") { + t.Errorf("a book with a pack and NO contrast artifact still cannot reach the stop, and still pays "+ + "for the term table on every draft call — it must be warned:\n%s", out) + } + }) + + // ARM 2 — a contrast artifact, but no pack. The mirror image, and the one a config loader structurally + // cannot see: the pack is book data, not pipeline data. + t.Run("a contrast artifact but no pack", func(t *testing.T) { + srv := newJSONProvider(&reqRec{}, draftEdit) + defer srv.Close() + bookPath := setupProjectOpts(t, srv.URL, projectOpts{banknote: true, + gatesYAML: "\nmining:\n contrast_path: mining-contrast.txt\n"}) + dir := filepath.Dir(bookPath) + writeFile(t, filepath.Join(dir, "mining-contrast.txt"), miningContrastData) + if out := open(t, bookPath, false); !strings.Contains(out, "banknote channel is ON") { + t.Errorf("a book with a contrast artifact and NO langpack cannot mine either, and must be warned:\n%s", out) + } + }) + + // ARM 3 — `forWrite`. A $0 read path buys nothing, so warning there teaches an operator to ignore the + // line on the run where it matters. Planted, dropping the guard entirely stayed green. + t.Run("a read-only open is quiet", func(t *testing.T) { + srv := newJSONProvider(&reqRec{}, draftEdit) + defer srv.Close() + bookPath := setupProjectOpts(t, srv.URL, projectOpts{banknote: true}) + // Premise: this very book WOULD be warned on a write open — otherwise the silence below proves nothing. + if out := open(t, bookPath, false); !strings.Contains(out, "banknote channel is ON") { + t.Fatalf("premise broken: the write open must warn for this fixture:\n%s", out) + } + if out := open(t, bookPath, true); strings.Contains(out, "banknote channel is ON") { + t.Errorf("a read-only open spends nothing, so it must not warn about what a write run would buy:\n%s", out) + } + }) +} diff --git a/backend/internal/pipeline/flagseverity_test.go b/backend/internal/pipeline/flagseverity_test.go index 925d658c..2c17f485 100644 --- a/backend/internal/pipeline/flagseverity_test.go +++ b/backend/internal/pipeline/flagseverity_test.go @@ -4,7 +4,9 @@ import ( "go/ast" "go/parser" "go/token" + "os" "strconv" + "strings" "testing" ) @@ -22,31 +24,75 @@ import ( // and would then certify the rot. This is the guarantee that the next flag someone adds cannot land in the // unknown bucket silently: it fails here on the day it is declared, not on the day it is misreported. func TestEveryFlagReasonIsRanked(t *testing.T) { - fset := token.NewFileSet() - f, err := parser.ParseFile(fset, "disposition.go", nil, 0) + // ⚠ EVERY FILE OF THE PACKAGE, not disposition.go alone — the first version of this test named one + // file, and a planting that declared a FlagReason anywhere else walked straight past it while the test + // still reported «every declared reason is ranked». The guarantee was one file narrower than its own + // sentence, which is the defect class this file exists to prevent, committed by the file itself. + entries, err := os.ReadDir(".") if err != nil { - t.Fatalf("parse disposition.go: %v", err) + t.Fatalf("read the package directory: %v", err) + } + var sources []string + for _, e := range entries { + n := e.Name() + if e.IsDir() || !strings.HasSuffix(n, ".go") || strings.HasSuffix(n, "_test.go") { + continue + } + sources = append(sources, n) + } + if len(sources) < 10 { + t.Fatalf("only %d source files found — the walk stopped seeing the package: %v", len(sources), sources) + } + fset := token.NewFileSet() + var decls []ast.Decl + for _, name := range sources { + f, perr := parser.ParseFile(fset, name, nil, 0) + if perr != nil { + t.Fatalf("parse %s: %v", name, perr) + } + decls = append(decls, f.Decls...) } var found int - for _, d := range f.Decls { + for _, d := range decls { gd, ok := d.(*ast.GenDecl) if !ok || gd.Tok != token.CONST { continue } - var lastType string for _, sp := range gd.Specs { vs, ok := sp.(*ast.ValueSpec) if !ok { continue } - if id, ok := vs.Type.(*ast.Ident); ok { - lastType = id.Name - } - if lastType != "FlagReason" || len(vs.Values) == 0 { + // ⚠ THE TYPE MUST BE WRITTEN ON THIS SPEC. An earlier version carried the previous spec's type + // forward, which is not how Go types a const block: a spec that has a VALUE and no type is its + // own UNTYPED constant and inherits nothing. That version raised a FALSE ALARM — an ordinary + // string constant placed after a FlagReason spec in the same block was accused of being an + // unranked flag, and a doc-URL constant would have failed this test with a message about the + // chapter passport. A test that accuses the innocent is not enforcing its own sentence, and this + // one exists to say what IS a declared reason. + // + // ⚠ AND BOTH GO SPELLINGS COUNT. `X FlagReason = "…"` writes the type in the spec; the equally + // ordinary `X = FlagReason("…")` writes it as a CONVERSION, leaving vs.Type nil — and a walk + // that looked only at vs.Type was blind to it, which an adversarial pass exhibited as a pair: + // the typed spelling of one declaration RED, the conversion spelling of the same declaration + // SURVIVED. The sentence that used to stand here — «a missed declaration would trip the found + // floor rather than pass silently» — was simply false: one missing constant leaves the count at + // 16 against a floor of 15, and nothing trips. + if len(vs.Values) == 0 { continue } - lit, ok := vs.Values[0].(*ast.BasicLit) - if !ok || lit.Kind != token.STRING { + var lit *ast.BasicLit + switch { + case isFlagReasonIdent(vs.Type): + lit, _ = vs.Values[0].(*ast.BasicLit) + default: + call, isCall := vs.Values[0].(*ast.CallExpr) + if !isCall || !isFlagReasonIdent(call.Fun) || len(call.Args) != 1 { + continue + } + lit, _ = call.Args[0].(*ast.BasicLit) + } + if lit == nil || lit.Kind != token.STRING { continue } val, err := strconv.Unquote(lit.Value) @@ -66,8 +112,8 @@ func TestEveryFlagReasonIsRanked(t *testing.T) { // The reader of this test must know it actually read something: a parse that silently matched nothing // would pass forever and guard nothing — the same failure class one layer up. if found < 15 { - t.Fatalf("only %d FlagReason constants were read out of disposition.go — the walk stopped matching "+ - "the source, and this test is no longer checking anything", found) + t.Fatalf("only %d FlagReason constants were read out of the package's %d source files — the walk "+ + "stopped matching the source, and this test is no longer checking anything", found, len(sources)) } if len(flagSeverity) != found { t.Errorf("flagSeverity has %d entries for %d declared reasons — a rank exists for a reason that no "+ @@ -99,3 +145,10 @@ func TestOffTargetLangIsRankedWithTheContentFailures(t *testing.T) { t.Error("a hard refusal must still outrank an off-target completion") } } + +// isFlagReasonIdent reports whether an expression names the FlagReason type — as a spec's type or as the +// function of a conversion. One helper for both spellings, so the walk cannot learn one and forget the other. +func isFlagReasonIdent(e ast.Expr) bool { + id, ok := e.(*ast.Ident) + return ok && id.Name == "FlagReason" +} diff --git a/backend/internal/pipeline/offtarget_corpus_test.go b/backend/internal/pipeline/offtarget_corpus_test.go index fa65c1ae..3042c2e0 100644 --- a/backend/internal/pipeline/offtarget_corpus_test.go +++ b/backend/internal/pipeline/offtarget_corpus_test.go @@ -199,7 +199,13 @@ func TestTheCorpusStillHoldsItsHardestSpecimens(t *testing.T) { // accounting under its own id — both invisible, because the extremum still held. The corpus is a // frozen measurement, so a digest over every row's accounting is the honest pin: re-taking it is a // deliberate act that re-records this line in the same commit. - corpusDigest = "8c77a0947b7bc72b0b62e99291bfbc0955df764635e368cf9b03590606f9d2c7" + // ⚠ IT COVERS EVERY FIELD, INCLUDING THE ONES NO MEASUREMENT READS. The first version folded only the + // six counting fields, and a planting that rewrote a specimen's `head` — and, worse, its + // `provenance` — passed with the whole ledger green. `provenance` is the ADDRESS a specimen was + // taken from and the address it would be re-taken from; a corpus whose numbers are right and whose + // provenance is wrong is unreproducible in the one way that matters, and nothing else in this file + // looks at that field beyond `!= ""`. + corpusDigest = "ac4c14ef47e0c42807f47c88abc49da672884864392930fb07ecf4fe71f8fa23" ) corpus := loadOffTargetCorpus(t) var offTargets, healthy int @@ -251,9 +257,10 @@ func TestTheCorpusStillHoldsItsHardestSpecimens(t *testing.T) { // The whole population, so that a row which is not an extremum cannot be softened, relabelled, swapped // for an easier one under its own id, or duplicated, without saying so. if got := offTargetCorpusDigest(corpus); got != corpusDigest { - t.Errorf("the corpus accounting changed:\n is: %s\n recorded: %s\nEvery row's id, label and "+ - "four counts feed this. If the corpus was legitimately re-taken, re-record the digest in the same "+ - "commit and say so in the report; if it was not, find out what edited the fixture.", got, corpusDigest) + t.Errorf("the corpus accounting changed:\n is: %s\n recorded: %s\nAll EIGHT fields of every "+ + "row feed this — id, label, the four counts, provenance and head. If the corpus was legitimately "+ + "re-taken, re-record the digest in the same commit and say so in the report; if it was not, find "+ + "out what edited the fixture.", got, corpusDigest) } } @@ -264,7 +271,8 @@ func offTargetCorpusDigest(corpus []offTargetSpecimen) string { sort.Slice(rows, func(i, j int) bool { return rows[i].ID < rows[j].ID }) h := sha256.New() for _, s := range rows { - fmt.Fprintf(h, "%s|%s|%d|%d|%d|%d\n", s.ID, s.Label, s.SourceScriptRunes, s.TotalRunes, s.Letters, s.TargetScriptLetters) + fmt.Fprintf(h, "%s|%s|%d|%d|%d|%d|%s|%s\n", s.ID, s.Label, s.SourceScriptRunes, s.TotalRunes, + s.Letters, s.TargetScriptLetters, s.Provenance, s.Head) } return hex.EncodeToString(h.Sum(nil)) } diff --git a/backend/internal/pipeline/volumeslot_test.go b/backend/internal/pipeline/volumeslot_test.go index ae96b936..86fe9ab0 100644 --- a/backend/internal/pipeline/volumeslot_test.go +++ b/backend/internal/pipeline/volumeslot_test.go @@ -846,3 +846,142 @@ func TestReconcileLeavesUnpaidAndReworkedUnitsAlone(t *testing.T) { t.Errorf("the sentence's paying count would over-report by one: %+v", rework.stop) } } + +// TestTheVolumeReportIsAskedBeforeTheCountersAreTruedUp pins the ORDER of bound() and reconcile() at the +// level where the order exists — the wave driver — because nothing did. +// +// ⚠ waverun.go's own comment calls this order load-bearing, says it was found by planting, and sends the +// reader to TestReconcileMovesAFlaggedCarryOutOfCarried. That test is a unit test of volumeScope: it proves +// reconcile moves a flagged carry out of Carried, and it cannot see WHERE the driver asks bound(). Planted — +// the bound() block moved to just after reconcile — the whole pipeline package stayed green while +// `res.Volume` came back nil, i.e. the run that finished a unit outside its grant and flagged it said +// NOTHING about it. That is the disclosure the report exists for, and «the comment explains it» is not a +// guarantee; this is the same «a claim about structure passed off as a guarantee» class the round found +// four times, here in a comment that is otherwise entirely correct. +// +// The fixture is the smallest run that can tell the two orders apart: ONE carried unit, nothing left, and +// the carry comes back FLAGGED — so bound() is true before reconcile (Carried==1) and false after it +// (Carried==0, Flagged==1). A carry that DELIVERS cannot see this, which is why the neighbouring +// TestOneCarriedUnitIsStillWorkOutsideTheGrant passes under the planting. +func TestTheVolumeReportIsAskedBeforeTheCountersAreTruedUp(t *testing.T) { + rec := &reqRec{} + var mu sync.Mutex + editorRefuses := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + body, _ := io.ReadAll(req.Body) + rec.record(string(body)) + if isEditBody(string(body)) { + mu.Lock() + refuse := editorRefuses + mu.Unlock() + if !refuse { + w.WriteHeader(http.StatusInternalServerError) // run 1: the unit is started and never ships + return + } + writeFakeCompletion(w, "I'm sorry, but I can't help with that request.", "refusal") + return + } + writeFakeCompletion(w, "ЧЕРНОВИК ПЕРЕВОДА", "stop") + })) + defer srv.Close() + bookPath := volumeBook(t, srv.URL, 1) + ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()}) + + r1 := newRunner(t, bookPath) + if _, err := r1.TranslateBook(ctx); err == nil { + t.Fatal("precondition: the editor was supposed to fail this run, leaving one started-but-unshipped unit") + } + r1.Close() + + mu.Lock() + editorRefuses = true + mu.Unlock() + r2 := newRunner(t, bookPath) + defer r2.Close() + r2.MaxUnits = 1 + res, err := r2.TranslateBook(ctx) + if err != nil { + t.Fatal(err) + } + // Premise: the run's ONLY work was the carried unit, and it flagged. Without both halves the assertion + // below would pass under either order and prove nothing. + if res.Flagged != 1 { + t.Fatalf("premise broken: the carried unit must come back FLAGGED, got flagged=%d over %d chunk(s)", + res.Flagged, len(res.Chunks)) + } + v := res.Volume + if v == nil { + t.Fatal("the run finished a unit outside its grant, paid for it, and it FLAGGED — and the run reported " + + "no volume at all. bound() is being asked AFTER reconcile has moved that carry out of Carried, so " + + "the one run that most needs the disclosure is the one that goes silent") + } + if !strings.Contains(v.String(), "PAID FOR BUT FLAGGED") { + t.Errorf("the report must name the money spent for no readable text: %s", v.String()) + } +} + +// TestTheVolumeReportIsAskedBeforeReconcileOnADraftOnlyPipeline is the draft-only twin of the test above, +// and it exists because the first version of that pin turned out to be carried by its neighbours. +// +// ⚠ The bound() decision sits BEFORE the fork into the draft-only and editor paths, so BOTH paths depend on +// its position — but only the editor path had a fixture that could tell the two orders apart. A planting +// that moved the decision after the editor path's reconcile reddened three draft-only tests for the wrong +// reason (they lost the report entirely, not out of order), and a planting that moved it after BOTH +// reconciles would have been caught by nothing on this shape. On a draft-only pipeline the SHIPPING wave is +// the draft one, so the same carried-and-flagged unit is reachable here and reports the same way. +func TestTheVolumeReportIsAskedBeforeReconcileOnADraftOnlyPipeline(t *testing.T) { + var mu sync.Mutex + calls, released := 0, false + rec := &reqRec{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + body, _ := io.ReadAll(req.Body) + rec.record(string(body)) + mu.Lock() + calls++ + n, rel := calls, released + mu.Unlock() + switch { + case rel: + // Run 2: the second draft stage answers, and REFUSES — the carried unit is finished and flagged. + writeFakeCompletion(w, "I'm sorry, but I can't help with that request.", "refusal") + case n == 1: + writeFakeCompletion(w, "ЧЕРНОВИК ПЕРЕВОДА", "stop") // stage 1 of run 1 checkpoints + default: + w.WriteHeader(http.StatusInternalServerError) // stage 2 of run 1 dies: started, never shipped + } + })) + defer srv.Close() + + eps := []chunktest.Chapter{{ID: "c1", Href: "c1.xhtml", Body: "

静かな図書館の朝1。

"}} + bookPath := setupProjectOpts(t, srv.URL, projectOpts{epub: eps, spine: []string{"c1"}, draftOnly: true, waveWorkers: 1}) + addSecondDraftStage(t, bookPath) + ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()}) + + r1 := newRunner(t, bookPath) + if _, err := r1.TranslateBook(ctx); err == nil { + t.Fatal("precondition: the second draft stage was supposed to fail this run") + } + r1.Close() + + mu.Lock() + released = true + mu.Unlock() + r2 := newRunner(t, bookPath) + defer r2.Close() + r2.MaxUnits = 1 + res, err := r2.TranslateBook(ctx) + if err != nil { + t.Fatal(err) + } + if res.Flagged != 1 { + t.Fatalf("premise broken: the carried unit must come back FLAGGED on the shipping (draft) wave, got "+ + "flagged=%d over %d chunk(s)", res.Flagged, len(res.Chunks)) + } + if res.Volume == nil { + t.Fatal("a draft-only run finished a unit outside its grant, paid for it, and it FLAGGED — and reported " + + "no volume at all. The bound() decision is being taken from counters reconcile has already trued up") + } + if !strings.Contains(res.Volume.String(), "PAID FOR BUT FLAGGED") { + t.Errorf("the report must name the money spent for no readable text: %s", res.Volume.String()) + } +}