package pipeline import ( "context" "testing" "textmachine/backend/internal/obs" "textmachine/backend/internal/store" ) // bankmoneyledger_test.go: the two figures an operator reads about the BANK's money, each checked against // the ledger rather than against the code that produces it (backlog rows 358 and 355). // // Both defects were found on the same live run and both are the same shape: the engine settled the money // correctly and then told the operator a number that the ledger does not contain. So the assertions here // take the report's figure and the ledger's rows on ONE fixture and state their relation — an equality for // the run total, a class membership for the decomposition. Reading the code instead would re-derive the // arithmetic being tested. // TestTheRunTotalIsThisRunsSpendIncludingTheClassifier is row 358, and the classifier is ON for a reason // that is the whole point of the fixture: with it off, ClassifyCostUSD is structurally zero and the // missing addend is invisible — a green test about a figure nobody added. TestTerminologistSpendIsInTheRunTotal // is exactly that fixture (`miningStopOpts{terminology: true}`, no classify) and it stays green either way. // // ⛔ THE INVARIANT IS «THIS RUN'S FRESH SPEND», NOT «THE LEDGER», and the second leg is what says so. On a // first run the two coincide, because every call was paid now — which is what makes the equality checkable // at all. On a RESUME they part: the ledger still holds the whole book while the run paid nothing, so a // total built from the terminology's CUMULATIVE figure (CumUSD, which counts replayed checkpoints at what // they cost then) would report money this run did not spend. That confusion is invisible on a first run, // where CostUSD and CumUSD are equal to the cent. // // Mutations this catches: drop the ClassifyCostUSD addend → leg 1 falls short by the classify phase's // spend; read CumUSD instead of CostUSD → leg 2 reports a paid run where nothing was bought. func TestTheRunTotalIsThisRunsSpendIncludingTheClassifier(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, func(body string) (string, string) { if isClassifierBody(body) { return "方源\tname\n青茅山\tterm", "stop" } if isTerminologyBody(body) { return "方源\tФан Юань\n青茅山\tгора Цинмао", "stop" } return "Фан Юань пришёл к горе Цинмао.\n" + bankBlockForMining, "stop" }) defer srv.Close() // batch_runes 1 splits both passes into three batches; the render budget then admits two of them while // the classify budget (1.0) admits all three. That asymmetry is deliberate and it is the only lever the // fixture has: the fake model bills a FIXED price per call, so two passes making the same number of // calls cost the same to the cent and a total built from the wrong addend would pass. The premise below // asserts the asymmetry rather than trusting it to survive a change in the estimate arithmetic. bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{ terminology: true, classify: true, batchRunes: 1, budgetUSD: 0.0037, }) // --- leg 1: the first run pays for everything it does, so its total IS the whole ledger --- r1 := newRunner(t, bookPath) // AUTO mode: the run has to finish for there to be a total at all res1, err := r1.TranslateBook(obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})) if err != nil { t.Fatal(err) } // The premise, stated as an assertion: BOTH bank passes must have paid, and their two figures must // DIFFER. A fixture where the render and the classify cost the same amount would pass whichever addend // the code used (D39.208 п.5), and a fixture where either is zero tests nothing at all. if r1.lastTerminology == nil { t.Fatal("premise broken: the terminology pass did not run, so this fixture measures nothing") } render, classify := r1.lastTerminology.CostUSD, r1.lastTerminology.ClassifyCostUSD if render <= 0 || classify <= 0 || render == classify { t.Fatalf("degenerate fixture: the render and classify phases must both have paid, and paid "+ "DIFFERENT amounts, or the sum cannot say which addend it is made of: render=%v classify=%v", render, classify) } committed1, _, err := r1.Store.SpentUSD("test-book") if err != nil { t.Fatal(err) } if committed1 <= 0 { t.Fatalf("premise broken: the ledger holds no money for this run (%v)", committed1) } if d := res1.TotalUSD - committed1; d > 1e-9 || d < -1e-9 { t.Fatalf("the run total an operator reads must be the ledger's own sum on a run that paid for "+ "everything it did: TOTAL=$%.9f ledger=$%.9f (short by $%.9f; the classify phase paid $%.9f)", res1.TotalUSD, committed1, committed1-res1.TotalUSD, classify) } _ = r1.Close() } // TestAReplayedBankContourIsFreeWhileItsCumulativeFigureRemembers is the second half of the run total's // contract — «the total reports what THIS run spent» — and it lives in its own fixture rather than as a // second leg of the test above. // // ⛔ WHY IT WAS SPLIT OUT, because a split is the kind of change that hides a weakening. The test above // needs a render budget that DROPS a batch: that asymmetry against the classifier's own budget is the only // lever its fixture has, since the fake model bills a fixed price per call and two passes making the same // number of calls cost the same to the cent. This test needs the opposite — a book with enough budget left // that a re-asked batch can be bought and then REPLAYED. With the settled basis in the tree the two // requirements became arithmetically incompatible on one fixture: admitting two of three batches costs the // whole ceiling, so the run that meets the basis's one-time re-pack can buy nothing, and a contour that // buys nothing has nothing to replay either. Both assertions survive; each now stands on a fixture that can // actually produce the state it is about, and each states its own premise. func TestAReplayedBankContourIsFreeWhileItsCumulativeFigureRemembers(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, func(body string) (string, string) { if isTerminologyBody(body) { // ONE of the three batches' terms is answered; the rest stay unanswered on every run, so there // is always a batch the basis cannot serve and the contour keeps something to replay. return "方源\tФан Юань\n青茅山\tгора Цинмао", "stop" } return "Фан Юань пришёл к горе Цинмао.\n" + bankBlockForMining, "stop" }) defer srv.Close() bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{ terminology: true, classify: true, batchRunes: 1, budgetUSD: 0.008, }) ctx := func() context.Context { return obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()}) } // Run 1 buys the pass. Run 2 meets the basis run 1 wrote, re-packs what is left and buys that ONCE. // Run 3 is the subject: the composition is stable, so the contour replays for $0 while its cumulative // figure still remembers what run 2 paid. r1 := newRunner(t, bookPath) if _, err := r1.TranslateBook(ctx()); err != nil { t.Fatal(err) } if r1.lastTerminology == nil || r1.lastTerminology.CostUSD <= 0 { t.Fatalf("premise broken: run 1 must BUY the pass, or a free replay later means nothing: %+v", r1.lastTerminology) } committedAfter1, _, err := r1.Store.SpentUSD("test-book") if err != nil { t.Fatal(err) } _ = r1.Close() r2 := newRunner(t, bookPath) if _, err := r2.TranslateBook(ctx()); err != nil { t.Fatal(err) } if r2.lastTerminology == nil || r2.lastTerminology.BasisServed == 0 { t.Fatalf("premise broken: run 2 must be served by the basis, or the re-pack has another cause: %+v", r2.lastTerminology) } if r2.lastTerminology.CostUSD <= 0 { t.Fatalf("premise broken: run 2 must pay the one-time re-pack, or run 3 has nothing to replay: fresh=$%.9f", r2.lastTerminology.CostUSD) } committedAfter2, _, err := r2.Store.SpentUSD("test-book") if err != nil { t.Fatal(err) } _ = r2.Close() r3 := newRunner(t, bookPath) defer func() { _ = r3.Close() }() res3, err := r3.TranslateBook(ctx()) if err != nil { t.Fatal(err) } if r3.lastTerminology == nil { t.Fatal("premise broken: the resumed run must still run the pass (replaying it), or this test is vacuous") } fresh, cumulative := r3.lastTerminology.CostUSD, r3.lastTerminology.CumUSD if fresh != 0 || cumulative <= 0 { t.Fatalf("premise broken: a resume must replay the contour for $0 while the cumulative figure keeps "+ "what it cost then: fresh=$%.9f cumulative=$%.9f", fresh, cumulative) } committedAfter3, _, err := r3.Store.SpentUSD("test-book") if err != nil { t.Fatal(err) } if d := res3.TotalUSD - (committedAfter3 - committedAfter2); d > 1e-9 || d < -1e-9 { t.Fatalf("the total reports what THIS run spent, not what the book has cost: TOTAL=$%.9f, this run "+ "added $%.9f to the ledger (%.9f → %.9f); the contour's cumulative figure is $%.9f and is NOT it", res3.TotalUSD, committedAfter3-committedAfter2, committedAfter2, committedAfter3, cumulative) } // The denominator that keeps the zero above readable: the book DID cost something over the three runs, // so «this run added nothing» is a statement about the run and not about an idle fixture. if committedAfter3 <= committedAfter1 { t.Fatalf("the ledger never grew past run 1 ($%.9f → $%.9f): nothing was ever re-packed, and the free replay is free for the wrong reason", committedAfter1, committedAfter3) } } // TestTheLedgerSaysTheClassifierBoughtTheBank is row 355 against the store rather than against a hand-built // slice: the two bank roles are planted through the REAL money path — reserve → settle+checkpoint — at the // position they actually collide on, and the decomposition is then read back through the same query the // report uses. // // The collision is not hypothetical arithmetic. The bank roles are checkpointed under one synthetic stage // at chapter 0 and number their batches in chunk_idx, so the classifier's batch 0 and the terminologist's // batch 0 are the same (chapter, chunk, stage) triple. Keyed on that triple alone, the earlier of the two — // the classifier, and on the live run the LARGEST of the three rows — is filed as money that bought // nothing. // // Mutation this catches: stop reading the role in posOf and the two roles share a position again → the // classifier lands in `superseded` → RED naming the amount. func TestTheLedgerSaysTheClassifierBoughtTheBank(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, draftEdit) defer srv.Close() bookPath := volumeBook(t, srv.URL, 2) ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()}) r := newRunner(t, bookPath) defer r.Close() if _, err := r.TranslateBook(ctx); err != nil { t.Fatal(err) } if err := r.Store.UpsertSnapshot("snap-terminology", "brief", `{"k":1}`); err != nil { t.Fatal(err) // jobs reference snapshots; the row has to exist before a job can point at one } job, err := r.Store.EnsureJob("test-book", 0, terminologyStageName, "snap-terminology") if err != nil { t.Fatal(err) } // The live run's own shape: the classifier's batch 0 first and dearest, then the terminologist's // batches 0 and 1. Three DISTINCT amounts, so no assertion below can be satisfied by the wrong row. const classifyUSD, renderUSD, renderNextUSD = 0.009013, 0.002775, 0.001820 plant := func(hash, role string, chunkIdx int, usd float64) { t.Helper() res, verdict, err := r.Store.Reserve("test-book", usd, store.Ceilings{BookUSD: 100, DayUSD: 100}) if err != nil || verdict != store.ReserveOK { t.Fatalf("reserve for %s: %v %v", hash, verdict, err) } if err := r.Store.SettleWithCheckpoint(res, usd, store.Checkpoint{ RequestHash: hash, JobID: job.ID, ChunkIdx: chunkIdx, Stage: terminologyStageName, Role: role, ModelRequested: "fake-model", ModelActual: "fake-model", ResponseText: "терм\tterm", UsageJSON: "{}", CostUSD: usd, FinishReason: "stop", }, nil); err != nil { t.Fatalf("settle for %s: %v", hash, err) } } plant("planted-classify-batch-0", roleClassifier, 0, classifyUSD) plant("planted-render-batch-0", roleTerminologist, 0, renderUSD) plant("planted-render-batch-1", roleTerminologist, 1, renderNextUSD) usage, err := r.Store.CheckpointUsageForBook("test-book") if err != nil { t.Fatal(err) } // The premise the whole fix rests on: the ledger DOES carry the role, and the two planted rows really // do share a (chapter, chunk, stage) triple. Without this, a query that dropped the column would leave // every assertion below passing for the wrong reason. var collide int for _, u := range usage { if u.Chapter == 0 && u.ChunkIdx == 0 && u.Stage == terminologyStageName { collide++ if u.Role == "" { t.Fatalf("the ledger must carry the role of a bank call, got %+v", u) } } } if collide != 2 { t.Fatalf("premise broken: exactly two planted rows share the batch-0 triple, got %d", collide) } statuses, err := r.Store.ChunkStatusesForBook("test-book") if err != nil { t.Fatal(err) } got := paidTail(usage, statuses) // (1) All three bank rows bought the bank. None of them replaced another: they are three purchases of // three different things, and only one of the three roles was even asked twice. if d := got.BankUSD - (classifyUSD + renderUSD + renderNextUSD); d > 1e-9 || d < -1e-9 || got.BankCalls != 3 { t.Fatalf("every bank call here stands and bought the book's terminology: bank=$%.9f calls=%d, "+ "want $%.9f over 3 calls", got.BankUSD, got.BankCalls, classifyUSD+renderUSD+renderNextUSD) } // (2) And specifically NOT this: the classifier reported as money that bought nothing. On the live run // it was the largest of the three rows, printed under a total that excluded it. if got.SupersededUSD != 0 || got.SupersededCalls != 0 { t.Fatalf("nothing here was replaced — the classifier and the terminologist bought different things "+ "at one synthetic address: superseded=$%.9f over %d call(s)", got.SupersededUSD, got.SupersededCalls) } if got.LostUSD() != 0 { t.Fatalf("this book lost nothing: %+v", got) } // (3) The decomposition still sums to the ledger it claims to decompose — the fix moves rows BETWEEN // classes and must not invent or drop a cent. committed, _, err := r.Store.SpentUSD("test-book") if err != nil { t.Fatal(err) } if d := got.TotalUSD - committed; d > 1e-9 || d < -1e-9 { t.Fatalf("the decomposition must sum to the committed ledger: $%.9f vs $%.9f", got.TotalUSD, committed) } } // TestTwoBankRolesInOneBatchAreTwoPositions is the arithmetic half, and the fixture the package did not // have: every existing paidtail fixture leaves Role empty, so the collision this fix removes could not // appear in one of them and the battery was green on the defect (D39.208 п.5). // // It also states the OTHER half of the rule, which a role-in-every-key change would have broken silently: // two calls of the SAME role at one position still replace each other. That is what makes a re-bought // contour a loss (backlog row 233) rather than an amnesty. func TestTwoBankRolesInOneBatchAreTwoPositions(t *testing.T) { usage := []store.CheckpointUsage{ {Chapter: 0, ChunkIdx: 0, Stage: terminologyStageName, Role: roleClassifier, CostUSD: 0.009}, {Chapter: 0, ChunkIdx: 0, Stage: terminologyStageName, Role: roleTerminologist, CostUSD: 0.002}, {Chapter: 0, ChunkIdx: 0, Stage: terminologyStageName, Role: roleTerminologist, CostUSD: 0.003}, } got := paidTail(usage, nil) // The classifier is untouched by the terminologist's re-purchase: different role, different position. // The terminologist's own first batch IS replaced by its second. if d := got.BankUSD - (0.009 + 0.003); d > 1e-9 || d < -1e-9 || got.BankCalls != 2 { t.Fatalf("the classifier's call and the terminologist's LAST call both stand: %+v", got) } if d := got.SupersededUSD - 0.002; d > 1e-9 || d < -1e-9 || got.SupersededCalls != 1 { t.Fatalf("only the terminologist's re-bought batch was replaced: %+v", got) } // The two positions must also be two NAMES: an operator sent to «book/batch0/terminology» cannot tell // which of the two roles lost the money. if got.WorstPosition != "book/batch0/terminology/terminologist" { t.Fatalf("the lost position must name the role that lost it, got %q", got.WorstPosition) } } // TestTheRoleSplitDoesNotMoveARepairsMoney is the BOUNDARY of the role split, and it guards the change // this pack did NOT make. // // A repair call carries its stage's real name and its own synthetic role (repair.go), so it lands on the // same (chapter, chunk, stage) position as the stage call it repairs — and, being later, it replaces it. // Folding the role into every position would separate the two and quietly move the repaired call's money // out of `superseded`: a second money change, on a different backlog row, landed under this pack's report. // // ⚠ What this pin asserts is that the classification did not MOVE, not that it is the right one. Whether a // stage call whose output a repair then fixed really «bought nothing» is a live question and it is left // open in this pack's report; the answer belongs to whoever takes that row, and this pin is what will make // their change visible instead of silent. func TestTheRoleSplitDoesNotMoveARepairsMoney(t *testing.T) { usage := []store.CheckpointUsage{ {Chapter: 1, ChunkIdx: 0, Stage: "edit", Role: "editor", CostUSD: 0.020}, {Chapter: 1, ChunkIdx: 0, Stage: "edit", Role: roleRepair, CostUSD: 0.010}, } statuses := []store.ChunkStatus{{Chapter: 1, ChunkIdx: 0, Stage: "edit", FinalHash: "h"}} got := paidTail(usage, statuses) if d := got.ShippedUSD - 0.010; d > 1e-9 || d < -1e-9 || got.ShippedCalls != 1 { t.Fatalf("the repair is the call that stands at this position, and its position shipped: %+v", got) } if d := got.SupersededUSD - 0.020; d > 1e-9 || d < -1e-9 || got.SupersededCalls != 1 { t.Fatalf("the two roles share a TEXT position and the later one replaces the earlier — this pack "+ "separates the BANK roles only: %+v", got) } // The two roles must actually differ, or the fixture would pass with the role read everywhere. if usage[0].Role == usage[1].Role { t.Fatal("degenerate fixture: the two calls must carry different roles") } }