textmachine/backend/internal/pipeline/bankrolemoney_test.go

218 lines
10 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"
"os"
"path/filepath"
"strings"
"testing"
"textmachine/backend/internal/obs"
"textmachine/backend/internal/store"
)
// bankrolemoney_test.go: backlog row 194 — the money of the BANK ROLES was outside every projection an
// operator decides on.
//
// The terminologist and the classifier are checkpointed under a synthetic stage at chapter 0 and write no
// chunk_status row, so any derivation that walks chunk_status cannot see them. On the cold run that was
// $0.04980482 of $0.43610966 — 11.4% of the run, invisible in the figure shown before deciding to buy more.
// TestTheBankContourEntersTheProjectionAndNotTheLedger is the landing AND the proof the ratification was
// conditioned on: the fix must move the PROJECTION and must not move one micro-dollar of anything the
// platform reads.
//
// The two halves are asserted separately because they are separate risks. `committed_usd` is summed from
// `spend`, and the platform meters against it (`bookCap = committed + increment`); the money of the bank
// roles was ALWAYS in there, because spend and checkpoints are written in one transaction. What was blind
// was the projection alone — so a correct fix changes exactly one of these two numbers.
//
// Mutation this catches: drop the `+ r.bankRoleCommittedUSD()` addend and the projection stops covering
// the contour → the first assertion fires with the exact shortfall.
func TestTheBankContourEntersTheProjectionAndNotTheLedger(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)
}
// The fixture runs no terminologist, so the contour is planted directly on its own axis — the same
// place the role writes: a checkpoint under the synthetic stage at chapter 0, with NO chunk_status row.
// Planting it is what makes the blindness observable at all; a book without bank-role money cannot
// distinguish a projection that includes the class from one that omits it.
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 it
}
job, err := r.Store.EnsureJob("test-book", 0, terminologyStageName, "snap-terminology")
if err != nil {
t.Fatal(err)
}
const contour = 0.05
res, verdict, err := r.Store.Reserve("test-book", contour, store.Ceilings{BookUSD: 100, DayUSD: 100})
if err != nil || verdict != store.ReserveOK {
t.Fatalf("reserve for the planted bank-role call: %v %v", verdict, err)
}
// The REAL money path — reserve → settle+checkpoint — because that is what the role does, and using
// it is what makes the second half of this test meaningful: the contour lands in `spend` AND in
// `checkpoints`, exactly as it always has, and the question is only whether the PROJECTION sees it.
if err := r.Store.SettleWithCheckpoint(res, contour, store.Checkpoint{
RequestHash: "planted-terminology-batch-0", JobID: job.ID, ChunkIdx: 0,
Stage: terminologyStageName, Role: roleTerminologist,
ModelRequested: "fake-model", ModelActual: "fake-model",
ResponseText: "терм\tterm", UsageJSON: "{}", CostUSD: contour, FinishReason: "stop",
}, nil); err != nil {
t.Fatal(err)
}
before, err := r.Status(ctx)
if err != nil {
t.Fatal(err)
}
// (1) THE PROJECTION MUST COVER IT. The per-unit walk cannot reach a chapter-0 synthetic stage, so a
// projection that still omits the contour is the defect row 194 names.
if before.ProjectedBookUSD < contour {
t.Fatalf("the book has spent $%.6f on its bank roles and the projection an operator decides on is "+
"$%.6f — a whole class of money missing from the number that invites the next purchase (row 194)",
contour, before.ProjectedBookUSD)
}
// (2) AND THE LEDGER MUST NOT MOVE. committed_usd is what the platform meters against; this fix is a
// correction of a projection and must not touch it. The invariant is checked directly rather than
// trusted: committed is summed from `spend`, and the planted row went to `checkpoints` alone.
committed, _, err := r.Store.SpentUSD("test-book")
if err != nil {
t.Fatal(err)
}
if before.CommittedUSD != committed {
t.Fatalf("status must report the ledger's own committed figure and nothing else: %.9f vs %.9f",
before.CommittedUSD, committed)
}
usage, err := r.Store.CheckpointUsageForBook("test-book")
if err != nil {
t.Fatal(err)
}
var checkpointed float64
for _, u := range usage {
checkpointed += u.CostUSD
}
// ⛔ THE CONDITION THE RATIFICATION WAS GIVEN UNDER, asserted rather than argued: the ledger figure the
// platform meters against is `SUM(checkpoints)` to the last micro-dollar, and it ALREADY held this
// money — spend and checkpoints are written in one transaction. So the bank contour was never missing
// from what the account is charged; it was missing from the FORECAST. A fix that moved this equality
// would be a contract change and would have to stop and ask.
if d := checkpointed - committed; d > 1e-9 || d < -1e-9 {
t.Fatalf("committed_usd must remain SUM(checkpoints) — the fix corrects a projection and must not "+
"touch a figure the platform reads: checkpoints $%.9f, committed $%.9f", checkpointed, committed)
}
// And the projection is strictly LARGER than the ledger here, which is the shape a forecast should
// have and the shape it did not have while a whole class of spend was invisible to it.
if before.ProjectedBookUSD < committed {
t.Fatalf("a book forecast below what the book has already been charged is not a forecast: "+
"projected $%.6f, committed $%.6f", before.ProjectedBookUSD, committed)
}
}
// TestABookWithNoBankRolesProjectsExactlyAsBefore keeps the fix from being a silent re-definition of the
// figure for every book: with no bank-role spend the addend is zero, so nothing about the ordinary book's
// projection moves.
func TestABookWithNoBankRolesProjectsExactlyAsBefore(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 got := r.bankRoleCommittedUSD(); got != 0 {
t.Fatalf("this fixture runs no bank role, so the addend must be exactly zero, got %.9f", got)
}
rep, err := r.Status(ctx)
if err != nil {
t.Fatal(err)
}
if rep.ProjectedBookUSD <= 0 {
t.Fatal("a translated book still projects a cost")
}
}
// TestTheContourDoesNotWeakenTheConsentGate is the orchestrator's decision (31.08), pinned.
//
// The bank contour belongs in «what will this book cost me» and NOT in the base of the re-payment consent
// threshold. The threshold is 5% of the projected book cost and it governs RE-PAYMENT: raising its base
// with money that is never re-paid by a snapshot move raises the bar for ASKING without adding anything
// the bar is about — a money gate made quietly weaker. The first version of the A10 fix did exactly that
// by adding the contour before the threshold was taken; the acceptance found it.
//
// Mutation this catches: add the contour back before rebillConsentThreshold and the threshold rises with
// it, so a re-payment that must be consented to slips under the bar → RED.
func TestTheContourDoesNotWeakenTheConsentGate(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
// A txt fixture, because the scenario edits the SOURCE in place and this book's source must be the file
// the edit touches.
// bookUSD well above the planted contour: the ONLY thing allowed to stop the second run is the consent
// gate, so a money ceiling cannot masquerade as the property under test.
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ГЛАВАА\fГЛАВАБ", regenerate: 0, bookUSD: 100})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r := newRunner(t, bookPath)
if _, err := r.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
// A bank contour LARGE next to the book, so that folding it into the threshold's base would visibly
// move the bar. Planted through the real money path, as the role would.
if err := r.Store.UpsertSnapshot("snap-terminology-2", "brief", `{"k":1}`); err != nil {
t.Fatal(err)
}
job, err := r.Store.EnsureJob("test-book", 0, terminologyStageName, "snap-terminology-2")
if err != nil {
t.Fatal(err)
}
res, verdict, err := r.Store.Reserve("test-book", 1.0, store.Ceilings{BookUSD: 100, DayUSD: 100})
if err != nil || verdict != store.ReserveOK {
t.Fatalf("reserve: %v %v", verdict, err)
}
if err := r.Store.SettleWithCheckpoint(res, 1.0, store.Checkpoint{
RequestHash: "planted-big-contour", JobID: job.ID, ChunkIdx: 0,
Stage: terminologyStageName, Role: roleTerminologist,
ModelRequested: "fake-model", ModelActual: "fake-model",
ResponseText: "терм\tterm", UsageJSON: "{}", CostUSD: 1.0, FinishReason: "stop",
}, nil); err != nil {
t.Fatal(err)
}
rep, err := r.Status(ctx)
if err != nil {
t.Fatal(err)
}
r.Close()
// The REPORTED projection must carry the contour…
if rep.ProjectedBookUSD < 1.0 {
t.Fatalf("«what will this book cost me» must not omit a whole class of spend: %.6f", rep.ProjectedBookUSD)
}
// …and the consent gate must NOT have been relaxed by it. An in-place SOURCE edit genuinely re-buys the
// already-billed rows (backlog row 238), which is the cheapest scenario that really re-pays — a dropped
// stage would not do, because projectRebill never re-bills rows of a stage the config no longer runs.
if err := os.WriteFile(filepath.Join(filepath.Dir(bookPath), "source.txt"),
[]byte("ГЛАВАА ПРАВЛЕНА\fГЛАВАБ"), 0o644); err != nil {
t.Fatal(err)
}
r2 := newRunner(t, bookPath)
defer r2.Close()
_, err = r2.TranslateBook(ctx)
if err == nil || !strings.Contains(err.Error(), "RE-PAY") {
t.Fatalf("a re-payment must still be consented to: a contour folded into the threshold's base "+
"would have raised the bar above it and let the spend through unasked. got: %v", err)
}
}