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