textmachine/backend/internal/pipeline/bankbasis_answers_test.go

326 lines
16 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"
"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")
}
}