textmachine/backend/internal/pipeline/bankmoneyledger_test.go

347 lines
18 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"
"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")
}
}