textmachine/backend/internal/pipeline/reprice_test.go

498 lines
20 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"
"path/filepath"
"strings"
"testing"
"textmachine/backend/internal/chunk"
"textmachine/backend/internal/obs"
"textmachine/backend/internal/store"
)
// reprice_test.go: the consent number after a VENDOR PRICE CHANGE (row 181). The scenario is the one
// that made the row material — DeepSeek's table moved on 16.08.2026 and the projection was still adding
// up what the units had cost before it, so the operator consented to a figure the reservation would beat
// by multiples. Every assertion here is on money the operator is SHOWN, not on money that moves: the
// gate still refuses before any reservation, and the ledger is untouched.
func newRepricerT(t *testing.T, r *Runner) *repricer {
t.Helper()
rp, err := r.newRepricer()
if err != nil {
t.Fatal(err)
}
return rp
}
// bumpModelPrices multiplies every price in the fixture models.yaml by factor — the vendor moving its
// table under a book that is already paid for.
func bumpModelPrices(t *testing.T, bookPath string, factor float64) {
t.Helper()
path := filepath.Join(filepath.Dir(bookPath), "models.yaml")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
old := "price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 }"
next := fmt.Sprintf("price: { input_per_m: %g, cached_per_m: %g, cache_write_per_m: 0, output_per_m: %g }",
1.0*factor, 0.1*factor, 2.0*factor)
changed := strings.ReplaceAll(string(raw), old, next)
if changed == string(raw) {
t.Fatal("setup: the fixture price line was not found in models.yaml")
}
writeFile(t, path, changed)
}
// TestRebillProjectionUsesTheCurrentPriceTable is the row-181 headline: units bought under the old table
// must be quoted at the NEW one, because that is the table the reservation and the settle will use.
func TestRebillProjectionUsesTheCurrentPriceTable(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
committedBefore, _, err := r1.Store.SpentUSD("test-book")
if err != nil {
t.Fatal(err)
}
r1.Close()
if committedBefore <= 0 {
t.Fatalf("setup: the first run must bill something, got %v", committedBefore)
}
const factor = 4.0 // DeepSeek's own move was ×3.0-4.7 per axis (D39.137)
bumpModelPrices(t, bookPath, factor)
driftPipelineVersion(t, bookPath)
r2 := newRunner(t, bookPath)
defer r2.Close()
statuses, err := r2.Store.ChunkStatusesForBook("test-book")
if err != nil {
t.Fatal(err)
}
chunks := chunksOf(t, r2)
proj, err := r2.projectRebill(statuses, chunks, func() ([]chunk.Chunk, error) { return chunks, nil }, newRepricerT(t, r2))
if err != nil {
t.Fatal(err)
}
if proj.Rows != 2 {
t.Fatalf("setup: both stages must be superseded, got %d row(s)", proj.Rows)
}
if proj.HistoricalRows != 0 {
t.Errorf("every unit here has its usage on file; %d were quoted from history instead", proj.HistoricalRows)
}
// The whole point: the projection is what the units cost TODAY, not what they cost when bought.
if want := factor * committedBefore; !nearUSD(proj.USD, want) {
t.Fatalf("re-payment projected at $%.6f but the current price table makes that work $%.6f — the operator would consent to the old table's number (row 181)",
proj.USD, want)
}
// The threshold's base moves with it, or the gate goes half-historical: 5% of a book costed in last
// season's money against an amount costed in this one.
rep, err := r2.Status(ctx)
if err != nil {
t.Fatal(err)
}
if want := factor * committedBefore; !nearUSD(rep.ProjectedBookUSD, want) {
t.Fatalf("status projected_book_usd = $%.6f, want $%.6f at the current table", rep.ProjectedBookUSD, want)
}
if !nearUSD(rep.RebillUSD, proj.USD) {
t.Fatalf("status quotes $%.6f where the consent gate projects $%.6f — the two must be one number", rep.RebillUSD, proj.USD)
}
// And the operator-facing sentence: it must name the table it used, and must not still promise a sum
// of stored cost_usd — the most visible place the old text would have gone on lying.
r2.Resnapshot = true
_, terr := r2.TranslateBook(ctx)
if terr == nil {
t.Fatal("the re-payment is over the threshold — the run must refuse")
}
msg := terr.Error()
if !strings.Contains(msg, fmt.Sprintf("~$%.6f", proj.USD)) {
t.Errorf("the refusal must quote the re-priced amount ~$%.6f; got: %s", proj.USD, msg)
}
if strings.Contains(msg, "stored cost_usd") {
t.Errorf("the consent text still describes the amount as the sum of stored cost_usd: %s", msg)
}
if !strings.Contains(msg, "CURRENT price table") {
t.Errorf("the consent text must say what the amount is priced with; got: %s", msg)
}
// The threshold printed in that same sentence is 5% of the RE-PRICED book, not of the old one.
if !strings.Contains(msg, fmt.Sprintf("projected book cost $%.6f", rep.ProjectedBookUSD)) {
t.Errorf("the threshold's base must be the re-priced book cost $%.6f; got: %s", rep.ProjectedBookUSD, msg)
}
// Nothing was bought to learn any of this.
committedAfter, reservedAfter, err := r2.Store.SpentUSD("test-book")
if err != nil {
t.Fatal(err)
}
if committedAfter != committedBefore || reservedAfter != 0 {
t.Errorf("a refused run must move no money: committed %v → %v, reserved %v", committedBefore, committedAfter, reservedAfter)
}
}
// driftPromptVersion moves the fixture's prompt_version from one token to another, so a test can drift
// the book a SECOND time (driftPipelineVersion only knows the first hop).
func driftPromptVersion(t *testing.T, bookPath, from, to string) {
t.Helper()
path := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
changed := strings.ReplaceAll(string(raw), "prompt_version: "+from, "prompt_version: "+to)
if changed == string(raw) {
t.Fatalf("setup: prompt_version %q not found in the fixture pipeline", from)
}
writeFile(t, path, changed)
}
// TestRebillProjectionAfterAReBuyQuotesOneGeneration is the end-to-end form of the regression: a book
// that has ALREADY been re-bought once (the very flow the consent gate governs) must still be quoted the
// cost of ONE re-payment, not the sum of everything it has ever been billed. Prices are never touched
// here, so the projection has to equal the historical per-generation cost exactly.
func TestRebillProjectionAfterAReBuyQuotesOneGeneration(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
r1.Close()
// Generation 2: drift both waves and consent to the re-payment, so the book now carries two
// checkpoints per unit while each chunk_status row records only the newer one's cost.
driftPromptVersion(t, bookPath, "v-test", "v-test-g2")
r2 := newRunner(t, bookPath)
r2.Resnapshot = true
r2.AcceptRebill = RebillConsent{Given: true}
if _, err := r2.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
committed, _, err := r2.Store.SpentUSD("test-book")
if err != nil {
t.Fatal(err)
}
r2.Close()
if !nearUSD(committed, 4*fakeCallUSD) {
t.Fatalf("setup: two generations of draft+edit must bill 4 calls, got $%.6f", committed)
}
// Generation 3 is only PROJECTED: what would one more re-payment cost?
driftPromptVersion(t, bookPath, "v-test-g2", "v-test-g3")
r3 := newRunner(t, bookPath)
defer r3.Close()
statuses, err := r3.Store.ChunkStatusesForBook("test-book")
if err != nil {
t.Fatal(err)
}
chunks := chunksOf(t, r3)
proj, err := r3.projectRebill(statuses, chunks, func() ([]chunk.Chunk, error) { return chunks, nil }, newRepricerT(t, r3))
if err != nil {
t.Fatal(err)
}
if want := 2 * fakeCallUSD; !nearUSD(proj.USD, want) {
t.Errorf("a re-bought book projects the cost of ONE more re-payment ($%.6f), got $%.6f — the projection is counting superseded generations", want, proj.USD)
}
if proj.HistoricalRows != 0 {
t.Errorf("every row here has its usage on file; %d were quoted from history", proj.HistoricalRows)
}
rep, err := r3.Status(ctx)
if err != nil {
t.Fatal(err)
}
if want := 2 * fakeCallUSD; !nearUSD(rep.ProjectedBookUSD, want) {
t.Errorf("projected_book_usd is what the book costs ONCE ($%.6f), got $%.6f", want, rep.ProjectedBookUSD)
}
}
// addModel appends a model to the fixture catalogue at `factor` times the fixture price.
func addModel(t *testing.T, bookPath, name string, factor float64) {
t.Helper()
path := filepath.Join(filepath.Dir(bookPath), "models.yaml")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
writeFile(t, path, string(raw)+fmt.Sprintf(`
%s:
provider: fake
price: { input_per_m: %g, cached_per_m: %g, cache_write_per_m: 0, output_per_m: %g }
`, name, 1.0*factor, 0.1*factor, 2.0*factor))
}
// TestRepriceCheckpointFallsBackToTheBilledAmount pins the one place where re-pricing is impossible and
// the honest answer is the historical figure: the billed-decode checkpoint, which settles the
// RESERVATION ESTIMATE against a `{}` usage (stagerun.go). Re-pricing that to $0 would delete real money
// from the consent number.
func TestRepriceCheckpointFallsBackToTheBilledAmount(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
addModel(t, bookPath, "fake-dear", 10.0) // a second, dearer model so "priced by which one" is decidable
r := newRunner(t, bookPath)
defer r.Close()
for _, tc := range []struct {
name string
cu store.CheckpointUsage
wantUSD float64
wantPriced bool
}{
{
name: "usage on file is re-priced",
cu: store.CheckpointUsage{ModelRequested: "fake-model", UsageJSON: `{"PromptTokens":1000,"CachedTokens":200,"CompletionTokens":500}`, CostUSD: 0.01},
wantUSD: fakeCallUSD,
wantPriced: true,
},
{
name: "billed 2xx with an unreadable body keeps its settled estimate",
cu: store.CheckpointUsage{ModelRequested: "fake-model", UsageJSON: `{}`, CostUSD: 0.0042},
wantUSD: 0.0042,
wantPriced: false,
},
{
// Money is priced by the model that ANSWERED, not the one that was asked — the same
// PriceForResponse ordering settle uses, and the axis an escalation hop rides.
name: "the model that answered sets the price",
cu: store.CheckpointUsage{ModelRequested: "fake-model", ModelActual: "fake-dear", UsageJSON: `{"PromptTokens":1000,"CachedTokens":200,"CompletionTokens":500}`, CostUSD: 0.01},
wantUSD: 10 * fakeCallUSD,
wantPriced: true,
},
{
name: "a genuinely $0 row is not a gap",
cu: store.CheckpointUsage{ModelRequested: "fake-model", UsageJSON: `{}`, CostUSD: 0},
wantUSD: 0,
wantPriced: true,
},
{
name: "unparseable usage keeps its billed amount",
cu: store.CheckpointUsage{ModelRequested: "fake-model", UsageJSON: `not json`, CostUSD: 0.0007},
wantUSD: 0.0007,
wantPriced: false,
},
} {
t.Run(tc.name, func(t *testing.T) {
usd, priced := r.repriceCheckpoint(tc.cu)
if !nearUSD(usd, tc.wantUSD) || priced != tc.wantPriced {
t.Fatalf("repriceCheckpoint = ($%.6f, priced=%v), want ($%.6f, priced=%v)", usd, priced, tc.wantUSD, tc.wantPriced)
}
})
}
}
// TestRepricerCountsOnlyTheCurrentGeneration is the regression an adversarial review of this pack found.
// Checkpoints are append-only across a book's whole life and carry no snapshot: a re-bought or
// source-edited unit accumulates a checkpoint per generation, while chunk_status.cost_usd is OVERWRITTEN
// with the current generation's cost alone. Re-pricing every checkpoint of the cell therefore quoted the
// operator a number that grew by a whole extra generation with each re-payment — the failure the pack
// exists to remove, reintroduced by its own fix.
func TestRepricerCountsOnlyTheCurrentGenerationOfCheckpoints(t *testing.T) {
rp := &repricer{cells: map[chunkKey]map[string][]repricedCall{
{chapter: 1, chunkIdx: 0}: {
// Two generations, oldest first, DELIBERATELY ASYMMETRIC: an expensive first generation and a
// cheap second one, so walking the wrong end gives a different answer instead of the same one.
"draft": {{then: 0.60, now: 2.40, priced: true}, {then: 0.05, now: 0.20, priced: true}},
// One generation, two attempts.
"edit": {{then: 0.01, now: 0.04, priced: true}, {then: 0.02, now: 0.08, priced: true}},
},
}}
for _, tc := range []struct {
name string
cs store.ChunkStatus
wantUSD float64
wantFromHistory bool
}{
{
// Walking from the oldest end would answer $2.40 here — the whole inference is the direction.
name: "only the newest generation is priced",
cs: store.ChunkStatus{Chapter: 1, ChunkIdx: 0, Stage: "draft", CostUSD: 0.05},
wantUSD: 0.20,
},
{
// A config REVERT rewrites the row with an OLDER call's cost while the newer, superseded call
// stays on file. The walk then overshoots the row's money, which proves the newest calls are
// not this row's — the amount falls back to what was billed, and says so.
name: "a reverted row overshoots and is quoted as billed, disclosed",
cs: store.ChunkStatus{Chapter: 1, ChunkIdx: 0, Stage: "draft", CostUSD: 0.60},
wantUSD: 0.60,
wantFromHistory: true,
},
{
name: "every attempt of the current generation counts",
cs: store.ChunkStatus{Chapter: 1, ChunkIdx: 0, Stage: "edit", CostUSD: 0.03},
wantUSD: 0.12,
},
{
// A skipped row never reached a provider and costs $0 — even where the same position was
// billed under an earlier generation whose checkpoints are still on file.
name: "a $0 row takes nothing from the orphans at its position",
cs: store.ChunkStatus{Chapter: 1, ChunkIdx: 0, Stage: "draft", CostUSD: 0},
wantUSD: 0,
},
{
// Money the surviving checkpoints do not account for is carried at its billed value rather
// than dropped: the conservative direction, and what the projection answered before re-pricing.
name: "unexplained money is carried as billed",
cs: store.ChunkStatus{Chapter: 1, ChunkIdx: 0, Stage: "edit", CostUSD: 0.05},
wantUSD: 0.12 + 0.02,
wantFromHistory: true,
},
{
name: "a row with no checkpoint at all keeps its billed amount",
cs: store.ChunkStatus{Chapter: 9, ChunkIdx: 0, Stage: "edit", CostUSD: 0.03},
wantUSD: 0.03,
wantFromHistory: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
usd, fromHistory := rp.usd(tc.cs)
if !nearUSD(usd, tc.wantUSD) || fromHistory != tc.wantFromHistory {
t.Fatalf("usd = ($%.6f, fromHistory=%v), want ($%.6f, fromHistory=%v)", usd, fromHistory, tc.wantUSD, tc.wantFromHistory)
}
})
}
}
func nearUSD(got, want float64) bool {
d := got - want
return d < 1e-9 && d > -1e-9
}
// TestProjectionBasisNamesWhatTheAmountIsMadeOf pins the operator-facing sentence itself: the parenthetical
// the consent is given to must say which table priced the amount, and must disclose any part of it that
// could not be re-priced instead of quietly rounding that away.
func TestProjectionBasisNamesWhatTheAmountIsMadeOf(t *testing.T) {
clean := projectionBasis(RebillProjection{Rows: 4})
if !strings.Contains(clean, "CURRENT price table") || strings.Contains(clean, "originally billed") {
t.Errorf("a fully re-priced amount names the table and claims nothing else; got: %s", clean)
}
mixed := projectionBasis(RebillProjection{Rows: 4, HistoricalRows: 1})
if !strings.Contains(mixed, "CURRENT price table") || !strings.Contains(mixed, "1 of the 4") {
t.Errorf("a partly un-re-priced amount must disclose how much of it is old money; got: %s", mixed)
}
}
// TestCheckpointsOfOneGenerationSumToTheRowsCost pins the PREMISE the generation rule stands on: within a
// single generation, a disposition row's cost_usd is exactly the sum of its own checkpoints' costs. The
// rule reads membership off that identity — walk the newest calls while they fit inside the row's money —
// so a future path that bills into cost_usd without a checkpoint, or checkpoints money the row never
// counts, would not break loudly; the projection would just start quoting the wrong number.
//
// The shapes it runs are a plain draft+edit book and an escalation hop (a different model billed under the
// SAME stage name, so both calls land in one cell). It does NOT build a retried attempt, a repair call or
// a billed-decode row — those are covered at unit level in repriceCheckpoint's own table.
func TestCheckpointsOfOneGenerationSumToTheRowsCost(t *testing.T) {
for _, tc := range []struct {
name string
setup func(t *testing.T, url string) string
reply func(body string) (string, string)
}{
{"plain draft+edit", func(t *testing.T, url string) string { return setupProject(t, url) }, draftEdit},
{"an escalation hop under the same stage name", func(t *testing.T, url string) string {
return setupEscalationProject(t, url, 1.0, nil)
}, echoOrClean},
} {
t.Run(tc.name, func(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, tc.reply)
defer srv.Close()
bookPath := tc.setup(t, srv.URL)
r := newRunner(t, bookPath)
defer r.Close()
if _, err := r.TranslateBook(obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})); err != nil {
t.Fatal(err)
}
statuses, err := r.Store.ChunkStatusesForBook(r.Book.BookID)
if err != nil {
t.Fatal(err)
}
if len(statuses) == 0 {
t.Fatal("setup: the run wrote no disposition rows")
}
usage, err := r.Store.CheckpointUsageForBook(r.Book.BookID)
if err != nil {
t.Fatal(err)
}
stored := map[chunkKey]map[string]float64{}
for _, cu := range usage {
k := chunkKey{cu.Chapter, cu.ChunkIdx}
if stored[k] == nil {
stored[k] = map[string]float64{}
}
stored[k][cu.Stage] += cu.CostUSD
}
rp := newRepricerT(t, r)
for _, cs := range statuses {
if cs.Disposition == string(DispSkipped) {
continue // never reached a provider, so it has no checkpoints to sum
}
if got := stored[chunkKey{cs.Chapter, cs.ChunkIdx}][cs.Stage]; !nearUSD(got, cs.CostUSD) {
t.Errorf("ch%d/chunk%d/%s: checkpoints sum to $%.6f but the row records $%.6f — the generation rule reads membership off this identity",
cs.Chapter, cs.ChunkIdx, cs.Stage, got, cs.CostUSD)
}
// And the rule consumes all of them: a residue here means the walk stopped early.
if _, fromHistory := rp.usd(cs); fromHistory {
t.Errorf("ch%d/chunk%d/%s: a single-generation row must be fully re-priced, not partly carried from history",
cs.Chapter, cs.ChunkIdx, cs.Stage)
}
}
})
}
}
// TestProjectionCountsRowsItCouldNotRePrice wires the disclosure end to end: rp.usd reporting an
// un-re-priced row must reach RebillProjection.HistoricalRows and therefore the operator's sentence.
// Without this the increment can be deleted and the whole suite stays green.
func TestProjectionCountsRowsItCouldNotRePrice(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
r1.Close()
driftPipelineVersion(t, bookPath)
r2 := newRunner(t, bookPath)
defer r2.Close()
statuses, err := r2.Store.ChunkStatusesForBook("test-book")
if err != nil {
t.Fatal(err)
}
chunks := chunksOf(t, r2)
// An EMPTY repricer is the store that lost its calls: every counted row falls back to what it was
// billed, and the projection has to say so rather than present the total as freshly re-priced.
proj, err := r2.projectRebill(statuses, chunks, func() ([]chunk.Chunk, error) { return chunks, nil }, &repricer{})
if err != nil {
t.Fatal(err)
}
if proj.Rows == 0 {
t.Fatal("setup: the drift must supersede both stages")
}
if proj.HistoricalRows != proj.Rows {
t.Fatalf("every row was quoted from history; the projection discloses %d of %d", proj.HistoricalRows, proj.Rows)
}
if basis := projectionBasis(proj); !strings.Contains(basis, fmt.Sprintf("%d of the %d", proj.Rows, proj.Rows)) {
t.Errorf("the consent sentence must disclose the un-re-priced part; got: %s", basis)
}
}