package pipeline import ( "context" "fmt" "os" "strings" "testing" "textmachine/backend/internal/seed" "gopkg.in/yaml.v3" ) // bankbasis_answers_test.go: the pin of the settled basis's ONE silent regression — the one §4.2 of the // pack calls the quietest thing that can go wrong here. // // ⛔ WHAT IT GUARDS. A basis that merely REMOVED settled candidates from the paid set would look like a // saving and be a loss. attachConsolidatedDst stamps nothing for a candidate the role did not answer, so // the emission falls back to the raw first-chunk banknote guess and the row drops from `draft` to `auto` // (miner.DeltaFile: `st.Dst = props[0].Dst` under `props[0].Via == ""`, and `st.Status` is only raised to // «draft» by a CONSOLIDATED dst). Where the drafts proposed nothing on the surface itself, the rendering // disappears entirely. // // ⚠ AND THE PAIR IS COMPARED, NOT THE RENDERING ALONE. A zeroed dst is visible — an empty cell on the // sheet. A row that quietly swapped its consolidated rendering for the first chunk's guess and its status // for `auto` looks NORMAL and travels on as an ordinary undecided row. Comparing `dst` alone would go // green on exactly the worse half, so every assertion below is over (dst, status) together. func TestTheBasisCarriesTheBoughtAnswerRatherThanDroppingIt(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, func(body string) (string, string) { if isClassifierBody(body) { // ⚠ THE CLASSIFIER IS ON, AND THE FIXTURE ANSWERS IT, because the answer has TWO halves bought // on the same money and reaching the row through different doors. With the classifier off, // `type` and `gender` are empty on every row, so a basis that dropped the classifier's half // entirely would leave this pin green — it would be comparing "" with "". Measured: with the // phase off, a planting that discards the type and the gender survived the pin unchanged. return "方源\tname\tmale\n青茅山\tterm\tnone", "stop" } if isTerminologyBody(body) { return "方源\tФан Юань\n青茅山\tгора Цинмао", "stop" } return "Фан Юань пришёл к горе Цинмао.\n" + bankBlockForMining, "stop" }) defer srv.Close() bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, classify: true}) // Run 1: the roles are paid, and what they decided is what the book now holds. r1 := newRunner(t, bookPath) if _, err := r1.TranslateBook(context.Background()); err != nil { t.Fatal(err) } if r1.lastTerminology == nil || r1.lastTerminology.Consolidated == 0 { t.Fatalf("premise broken: run 1 must consolidate something, or there is no bought answer to lose: %+v", r1.lastTerminology) } bought := autoBankPairs(t, r1.autoBankPath()) decided := 0 for _, p := range bought { if p.status == "draft" && p.dst != "" { decided++ } } if decided == 0 { t.Fatalf("premise broken: run 1 emitted no `draft` row with a rendering, so the loss this test is about cannot happen in this fixture: %+v", bought) } // The classifier's half must actually be PRESENT, or the type/gender comparisons below are "" against // "" — the exact way this pin was measured to pass over a planting that threw both away. withType, withGender := 0, 0 for _, p := range bought { if p.typ != "" { withType++ } if p.gender != "" { withGender++ } } if withType == 0 || withGender == 0 { t.Fatalf("premise broken: the classifier's half of the answer is absent from run 1 (rows with a type: %d, with a gender: %d), so this pin cannot see it being lost: %+v", withType, withGender, bought) } r1.Close() // Run 2 meets the basis run 1 wrote and pays its one-time re-pack; run 3 is the steady state where the // basis actually answers. Both are walked, because the property must hold in the steady state and not // merely survive one hop. var last map[string]bankPair for i := 2; i <= 3; i++ { r := newRunner(t, bookPath) if _, err := r.TranslateBook(context.Background()); err != nil { t.Fatalf("run %d: %v", i, err) } if r.lastTerminology == nil || r.lastTerminology.BasisServed == 0 { t.Fatalf("premise broken: run %d must be served by the basis, or this test never exercises it: %+v", i, r.lastTerminology) } last = autoBankPairs(t, r.autoBankPath()) r.Close() } // THE ASSERTION: every row the book had bought still says exactly what it said. for src, was := range bought { now, present := last[src] if !present { t.Errorf("%q vanished from the auto-bank once the basis answered it: the book lost a rendering it had PAID for", src) continue } if now != was { t.Errorf("%q changed under the basis: was dst=%q status=%q type=%q gender=%q, now dst=%q status=%q type=%q gender=%q.\n"+ " A `draft` row falling to `auto` is the silent half of this regression: the row still LOOKS ordinary, "+ "it simply carries the first chunk's guess instead of the rendering the roles were paid for.", src, was.dst, was.status, was.typ, was.gender, now.dst, now.status, now.typ, now.gender) } } // The control that keeps those comparisons from being vacuous: the second reading is not empty, and it // is not a copy of a file nobody rewrote. if len(last) == 0 { t.Fatal("control: the steady-state auto-bank holds NO rows, so «everything matches» is a statement about an empty map") } } // bankPair is one auto-bank row reduced to the two fields that decide whether an answer survived. // ⚠ TYPE AND GENDER RIDE HERE TOO. They are the CLASSIFIER's half of the answer, bought on the same // money, and they reach the row by a different door (attachClassifiedType / attachClassifiedGender). A // pin over the rendering alone would go green on a basis that carried the terminologist's word and // dropped the classifier's — and a row that loses its gender loses a wire directive the engine already // promised. type bankPair struct{ dst, status, typ, gender string } // autoBankPairs reads the engine's own auto-bank document — the artifact the emission actually produces — // rather than a projection of it, so the pin cannot pass on a view that agrees with itself while the // document beneath disagrees. func autoBankPairs(t *testing.T, path string) map[string]bankPair { t.Helper() raw, err := os.ReadFile(path) if err != nil { t.Fatalf("the run must leave an auto-bank behind, or the emission never happened: %v", err) } var f seed.File if err := yaml.Unmarshal(raw, &f); err != nil { t.Fatalf("parse the auto-bank %s: %v", path, err) } out := map[string]bankPair{} for _, term := range f.Terms { out[strings.TrimSpace(term.Src)] = bankPair{dst: term.Dst, status: term.Status, typ: term.Type, gender: term.Gender} } if len(out) == 0 { t.Fatalf("the auto-bank at %s holds no terms (%d bytes): every comparison against it would be vacuous", path, len(raw)) } return out } // TestTheBasisIsNotWrittenBeforeTheRunReachesABoundary is the pin §4.3 of the pack asks for: a planting // that records the basis straight after the paid pass — before the run forks into «stop» or «continue» — // has to turn a FREE resume into a paid one. // // ⛔ THE MECHANISM, because the failure is not obvious. A run that dies after the bank roles have been paid // leaves their checkpoints behind, and a resume rebuilds the SAME batch composition and replays them for // $0. Write the basis before the fork and the crashed run has already recorded its decisions: the resume // then serves them, builds a NARROWER composition, finds no checkpoint addressed by that request hash, and // buys the whole pass again — money out of a lifetime ceiling, for work already done, with nothing red. // // The crash is produced by the BOOK CEILING rather than by a killed process: the draft wave and the bank // roles fit under it, the edit wave's reservation does not, so TranslateBook returns a refusal at exactly // the position this pin needs — after the paid pass, before either output boundary. func TestTheBasisIsNotWrittenBeforeTheRunReachesABoundary(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, func(body string) (string, string) { if isTerminologyBody(body) { return "方源\tФан Юань\n青茅山\tгора Цинмао", "stop" } return "Фан Юань пришёл к горе Цинмао.\n" + bankBlockForMining, "stop" }) defer srv.Close() bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true}) tightenBookCeiling(t, bookPath, 0.004) r1 := newRunner(t, bookPath) _, err := r1.TranslateBook(context.Background()) if err == nil { t.Fatal("premise broken: the tightened ceiling must STOP this run before it reaches an output boundary, or nothing here is about a crash") } if r1.lastTerminology == nil || r1.lastTerminology.CostUSD == 0 { t.Fatalf("premise broken: the bank roles must have been PAID before the run died, or there are no checkpoints for the resume to replay: %+v", r1.lastTerminology) } paidCalls := countTerminologyCalls(rec) // THE STATE THIS PIN IS ABOUT: the run died, so no boundary ran, so nothing was recorded. stored, serr := r1.Store.BankBasisForBook(r1.Book.BookID) if serr != nil { t.Fatal(serr) } if len(stored) != 0 { t.Fatalf("a run that never reached an output boundary recorded %d basis row(s): the next resume will serve them, narrow the batch composition, miss every checkpoint and re-buy the pass it already paid for", len(stored)) } r1.Close() // The consequence, asserted rather than inferred: the resume replays for $0. r2 := newRunner(t, bookPath) defer r2.Close() if _, err := r2.TranslateBook(context.Background()); err == nil { t.Fatal("premise broken: the resume must meet the same ceiling, or it is not the run this pin is about") } if r2.lastTerminology == nil { t.Fatal("premise broken: the resumed run must reach the bank pass again") } if r2.lastTerminology.CostUSD != 0 { t.Errorf("the resume re-bought the bank pass ($%f) although the crashed run had paid for it: the basis is being recorded before the run reaches a boundary", r2.lastTerminology.CostUSD) } if n := countTerminologyCalls(rec); n != paidCalls { t.Errorf("the resume reached the provider for the bank role: calls went %d → %d", paidCalls, n) } } // tightenBookCeiling rewrites the fixture's book ceiling in place. It edits the generated book.yaml rather // than adding an option to the fixture builder, because the ceiling is the only thing this one pin needs // and a knob nothing else sets is a knob that goes stale unread. func tightenBookCeiling(t *testing.T, bookPath string, usd float64) { t.Helper() raw, err := os.ReadFile(bookPath) if err != nil { t.Fatal(err) } out, replaced := []string{}, false for _, ln := range strings.Split(string(raw), "\n") { if strings.Contains(ln, "book_usd") { out = append(out, fmt.Sprintf("ceilings: { book_usd: %g, day_usd: 10.0 }", usd)) replaced = true continue } out = append(out, ln) } if !replaced { t.Fatalf("the fixture's book.yaml declares no book_usd, so this helper silently changed nothing:\n%s", raw) } if err := os.WriteFile(bookPath, []byte(strings.Join(out, "\n")), 0o644); err != nil { t.Fatal(err) } } // TestABookWithTheGateOffTouchesNoBasisAtAll pins the promise every new mechanism owes the books already in // the field: a project that does not buy bank roles must behave exactly as it did before this one existed. // // ⛔ WHY IT IS A PIN AND NOT AN OBSERVATION. The basis lives on the paid path, and the paid path is gated // (`gates.terminology.enabled`). A gate whose OFF branch grew a side effect is the quiet way a mechanism // reaches books nobody enabled it for — here it would mean rows in a table for a contour that never ran, // and a later purchase serving answers nobody bought. The engine's own refusals are written against that // shape everywhere else; this is the same refusal, asserted. func TestABookWithTheGateOffTouchesNoBasisAtAll(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, func(body string) (string, string) { return "Фан Юань пришёл к горе Цинмао.\n" + bankBlockForMining, "stop" }) defer srv.Close() // terminology OFF: the mining stop still runs, the delta is still emitted, nothing is bought. bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{}) r := newRunner(t, bookPath) defer r.Close() if _, err := r.TranslateBook(context.Background()); err != nil { t.Fatal(err) } // The premise: the run really did reach the bank-mining stop and emit a delta. Without it the zeros // below would be a statement about a run that never got near the contour. if r.lastMinedCount == 0 { t.Fatalf("premise broken: the run mined nothing, so «the gate-off path touched no basis» is about a path that was never walked") } if r.lastTerminology != nil { t.Fatalf("premise broken: the terminology pass ran with the gate OFF: %+v", r.lastTerminology) } stored, err := r.Store.BankBasisForBook(r.Book.BookID) if err != nil { // The TABLE must exist — the migration is additive and runs for every project — so a read error // here is a real failure, not an absent feature. t.Fatalf("the basis table must exist on every project, gate or no gate: %v", err) } if len(stored) != 0 { t.Fatalf("a run with the bank gate OFF recorded %d basis row(s): the mechanism reached a book nobody enabled it for, and a later purchase would serve answers nobody bought", len(stored)) } } // TestTheSignatureStopWritesTheBasisToo pins the boundary nothing was guarding, and acceptance proved the // hole with a control rather than by reading: removing the write at `run-finished` turned the package RED, // removing it at the SIGNATURE STOP left it entirely `ok`. // // ⛔ WHY THE UNGUARDED ONE IS THE EXPENSIVE ONE. The two boundaries are not interchangeable and the stop is // the one that cannot be reached from the other: the signature stop returns as an ERROR VALUE // (`WaveSignatureStop`), so a run that stops for the owner's signature never arrives at `run-finished` at // all. Lose the write there and the resume after the owner signs re-buys the ENTIRE bank pass — out of a // lifetime per-book ceiling — while every fixture that only ever auto-continues stays green. func TestTheSignatureStopWritesTheBasisToo(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, miningStopProvider(t, rec, "")) defer srv.Close() bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true}) r := newVerifyRunner(t, bookPath) defer r.Close() stop := runToSignatureStop(t, r) if stop.Terms == 0 { t.Fatal("premise broken: the stop fired with zero terms") } // The premise that makes the assertion about the STOP and not about a finished run: this run ended as // an error value, so `run-finished` never ran. if r.lastTerminology == nil || len(r.lastTerminology.BasisRows) == 0 { t.Fatalf("premise broken: the stopping run must have decided something to record: %+v", r.lastTerminology) } stored, err := r.Store.BankBasisForBook(r.Book.BookID) if err != nil { t.Fatal(err) } if len(stored) == 0 { t.Fatalf("the signature stop recorded NOTHING although it decided %d row(s): the resume after the owner signs would re-buy the whole bank pass out of a lifetime ceiling, and no fixture that auto-continues would ever notice", len(r.lastTerminology.BasisRows)) } if len(stored) != len(r.lastTerminology.BasisRows) { t.Errorf("the stop recorded %d row(s) of %d decided — a partial write is the same loss, only harder to see", len(stored), len(r.lastTerminology.BasisRows)) } // AND THE ORDER, which is the other half of this boundary's contract: the basis goes in BEFORE the flag // memory, because a crash between them must cost the cheaper loss. Asserted on the durable state: the // memory is present too, so «basis written» is not true merely because the stop died early. presented, err := r.Store.StopPresentedSurfaces(r.Book.BookID) if err != nil { t.Fatal(err) } if len(presented) == 0 { t.Fatal("control: the flag memory is empty, so this stop did not complete its boundary and the assertion above is about a half-written state") } }