textmachine/backend/internal/pipeline/reprice_test.go

735 lines
33 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"
"regexp"
"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))
}
// setStageModel points a fixture stage at another model — the stage's ROUTING moving under rows that
// were answered by the old one (D39.150 п.1).
func setStageModel(t *testing.T, bookPath, stage, model string) {
t.Helper()
path := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
lines := strings.Split(string(raw), "\n")
found := false
stageModel := regexp.MustCompile(`model: [^,]+,`)
for i, l := range lines {
if !strings.Contains(l, "name: "+stage+",") {
continue
}
if !stageModel.MatchString(l) {
t.Fatalf("setup: stage %q carries no model: line: %s", stage, l)
}
lines[i], found = stageModel.ReplaceAllString(l, "model: "+model+","), true
}
if !found {
t.Fatalf("setup: stage %q not found in the fixture pipeline", stage)
}
writeFile(t, path, strings.Join(lines, "\n"))
}
// 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.
//
// It also pins the MODEL axis (D39.150 п.1) at the call level: a stored call is priced at the model its
// stage resolves to TODAY and never below the answering model's price — the dearer of the two — and says
// when it did so. The stage is the fixture's `draft`; `rDear` is the same book after that stage moved to a
// model ten times the price.
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
// One runner at a time: the store's lock refuses a second writer on the same book, so the cases that
// need the stage moved to the dear model run after the first runner is closed (`dear` below).
const usage = `{"PromptTokens":1000,"CachedTokens":200,"CompletionTokens":500}`
for _, tc := range []struct {
name string
dear bool // the draft stage resolves to fake-dear (ten times the price) when this case runs
cu store.CheckpointUsage
wantUSD float64
wantPriced bool
wantMoved bool
}{
{
name: "usage on file is re-priced",
cu: store.CheckpointUsage{ModelRequested: "fake-model", UsageJSON: usage, 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,
},
{
// With no stage to resolve against, the model that ANSWERED sets the price — the same
// PriceForResponse ordering settle uses, so a canonicalised slug still finds its table.
name: "with no stage in the pipeline the model that answered sets the price",
cu: store.CheckpointUsage{ModelRequested: "fake-model", ModelActual: "fake-dear", UsageJSON: usage, 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,
},
{
name: "a stage still on its model is priced exactly and not marked as moved",
cu: store.CheckpointUsage{Stage: "draft", ModelRequested: "fake-model", ModelActual: "fake-model", UsageJSON: usage, CostUSD: 0.01},
wantUSD: fakeCallUSD,
wantPriced: true,
},
{
// The acceptance's own measurement, one call wide: the stage now resolves to a model ten
// times the price, and the number must follow the model that WILL answer, not the one that did.
name: "a stage moved to a dearer model prices at the dearer model, disclosed",
dear: true,
cu: store.CheckpointUsage{Stage: "draft", ModelRequested: "fake-model", ModelActual: "fake-model", UsageJSON: usage, CostUSD: 0.01},
wantUSD: 10 * fakeCallUSD,
wantPriced: true,
wantMoved: true,
},
{
// The other direction rounds UP too: the answering model's price is a floor, because the token
// count is that model's and the number must never be the smaller one silently.
name: "a stage moved to a cheaper model keeps the answering model's price as its floor",
cu: store.CheckpointUsage{Stage: "draft", ModelRequested: "fake-dear", ModelActual: "fake-dear", UsageJSON: usage, CostUSD: 0.1},
wantUSD: 10 * fakeCallUSD,
wantPriced: true,
wantMoved: true,
},
{
// The stage still resolves to what was asked, but a KNOWN cheaper slug answered (a router's
// fallback, a dated slug the table lists cheaper): the re-run asks for the stage's model again, so
// its price is the bound — and no routing move is claimed, because the stage did not move.
name: "a cheaper slug that answered for an unmoved stage does not pull the price below the stage's model",
dear: true,
cu: store.CheckpointUsage{Stage: "draft", ModelRequested: "fake-dear", ModelActual: "fake-model", UsageJSON: usage, CostUSD: 0.01},
wantUSD: 10 * fakeCallUSD,
wantPriced: true,
},
{
// A $0 row with NO usage on file is the derived checkpoint — free, and free again.
name: "a derived $0 row with no usage stays $0 and is not a gap",
cu: store.CheckpointUsage{Stage: "draft", ModelRequested: "fake-model", UsageJSON: `{}`, CostUSD: 0},
wantUSD: 0,
wantPriced: true,
},
{
// The same shape once the stage MOVED: there are no tokens to price at the new model, so the
// amount stays what is known and the row is reported as one that could not be re-priced. A bare
// $0 with no caveat would be the silent under-quote.
name: "a $0 row with no usage under a moved stage is disclosed, not published as free",
dear: true,
cu: store.CheckpointUsage{Stage: "draft", ModelRequested: "fake-model", UsageJSON: `{}`, CostUSD: 0},
wantUSD: 0,
wantPriced: false,
},
{
// A stage the current pipeline does not run has no «today»: nothing will re-buy the row
// (rebill.go), so its answering price stands and no routing move is claimed.
name: "a retired stage keeps the answering model's price",
dear: true,
cu: store.CheckpointUsage{Stage: "polish", ModelRequested: "fake-model", ModelActual: "fake-model", UsageJSON: usage, CostUSD: 0.01},
wantUSD: fakeCallUSD,
wantPriced: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
if tc.dear {
setStageModel(t, bookPath, "draft", "fake-dear")
} else {
setStageModel(t, bookPath, "draft", "fake-model")
}
r := newRunner(t, bookPath)
defer r.Close()
usd, priced, moved := r.repriceCheckpoint(tc.cu)
if !nearUSD(usd, tc.wantUSD) || priced != tc.wantPriced || moved != tc.wantMoved {
t.Fatalf("repriceCheckpoint = ($%.6f, priced=%v, moved=%v), want ($%.6f, priced=%v, moved=%v)",
usd, priced, moved, tc.wantUSD, tc.wantPriced, tc.wantMoved)
}
})
}
}
// TestAnEscalationCallIsPricedAtTheHopTheStageResolvesToNow is the routing move on the OTHER slug a
// stage carries. A hop call's tokens went to escalate_to, so «the model this stage resolves to now» is the
// current hop for that row — comparing it against the primary would call every escalated row a move and
// price it at the wrong table.
func TestAnEscalationCallIsPricedAtTheHopTheStageResolvesToNow(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, echoOrClean)
defer srv.Close()
bookPath := setupEscalationProject(t, srv.URL, 1.0, nil)
addModel(t, bookPath, "fake-dear", 10.0)
const usage = `{"PromptTokens":1000,"CachedTokens":200,"CompletionTokens":500}`
same := newRunner(t, bookPath)
// The hop still resolves to the model that answered: exact, and not a move.
if usd, _, moved := same.repriceCheckpoint(store.CheckpointUsage{Stage: "draft", ModelRequested: "fake-fallback", ModelActual: "fake-fallback", UsageJSON: usage, CostUSD: 0.01, Escalation: true}); !nearUSD(usd, fakeCallUSD) || moved {
t.Fatalf("an escalation call whose hop did not move: got ($%.6f, moved=%v), want ($%.6f, moved=false)", usd, moved, fakeCallUSD)
}
same.Close() // the store's lock admits one writer per book
// The hop moves to a dearer model: the escalated row follows the HOP, the primary rows do not.
path := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
changed := strings.Replace(string(raw), "escalate_to: fake-fallback", "escalate_to: fake-dear", 1)
if changed == string(raw) {
t.Fatal("setup: the fixture's escalate_to was not found")
}
writeFile(t, path, changed)
dearHop := newRunner(t, bookPath)
defer dearHop.Close()
if usd, _, moved := dearHop.repriceCheckpoint(store.CheckpointUsage{Stage: "draft", ModelRequested: "fake-fallback", ModelActual: "fake-fallback", UsageJSON: usage, CostUSD: 0.01, Escalation: true}); !nearUSD(usd, 10*fakeCallUSD) || !moved {
t.Fatalf("an escalation call under a moved hop: got ($%.6f, moved=%v), want ($%.6f, moved=true)", usd, moved, 10*fakeCallUSD)
}
if usd, _, moved := dearHop.repriceCheckpoint(store.CheckpointUsage{Stage: "draft", ModelRequested: "fake-model", ModelActual: "fake-model", UsageJSON: usage, CostUSD: 0.01}); !nearUSD(usd, fakeCallUSD) || moved {
t.Fatalf("the PRIMARY call of the same stage is untouched by a hop move: got ($%.6f, moved=%v), want ($%.6f, moved=false)", usd, moved, fakeCallUSD)
}
}
// 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}},
},
// One generation whose second call settled a billed-decode estimate: no usage to re-price.
{chapter: 2, chunkIdx: 0}: {
"draft": {{then: 0.03, now: 0.12, priced: true}, {then: 0.04, now: 0.04, priced: false}},
},
// Two generations again, the newer one priced across a model move (its stage resolves elsewhere now).
{chapter: 3, chunkIdx: 0}: {
"draft": {{then: 0.60, now: 2.40, priced: true}, {then: 0.01, now: 0.10, priced: true, moved: true}},
},
// A $0 generation (a model priced at zero) after a paid one, its stage moved to a paid model since.
{chapter: 4, chunkIdx: 0}: {
"draft": {{then: 0.05, now: 0.05, priced: true}, {then: 0, now: 0.02, priced: true, moved: true}, {then: 0, now: 0.02, priced: true, moved: true}},
},
// ONE generation whose primary was FREE (a $0 local model) and whose escalation hop was PAID: the
// row's whole cost is the hop's, so the hop alone accounts for it — and the free primary, which the
// re-run will make at the stage's new paid model, sits older in the same cell.
{chapter: 5, chunkIdx: 0}: {
"draft": {{then: 0, now: 0.02, priced: true, moved: true}, {then: 0.01, now: 0.01, priced: true}},
},
}}
for _, tc := range []struct {
name string
cs store.ChunkStatus
wantUSD float64
wantFromHistory bool
wantMoved 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. ⚠ The disposition is
// SET here rather than left empty: «skipped» is the premise of the case, and it is what tells
// a genuinely free row from one whose generation could not be established (the case below).
name: "a $0 SKIPPED row takes nothing from the orphans at its position",
cs: store.ChunkStatus{Chapter: 1, ChunkIdx: 0, Stage: "draft", CostUSD: 0, Disposition: string(DispSkipped)},
wantUSD: 0,
},
{
// The same shape WITHOUT the skip: a $0 row whose newest call on file is somebody else's. No
// walk can overshoot $0, so the revert detection above cannot see it; quoting a bare $0 would
// be a silent under-quote, so the row is reported as one that could not be re-priced.
name: "a $0 row whose generation cannot be established is disclosed, not published as free",
cs: store.ChunkStatus{Chapter: 1, ChunkIdx: 0, Stage: "draft", CostUSD: 0},
wantUSD: 0,
wantFromHistory: true,
},
{
// And the OTHER side of that guard: a $0 row with no call at all on file is genuinely free —
// there is no generation to fail to establish, so disclosing it would inflate the count of
// units the operator is told could not be re-priced with rows that cost nothing by construction.
name: "a $0 row with no call on file is free, not undisclosed",
cs: store.ChunkStatus{Chapter: 8, ChunkIdx: 0, Stage: "edit", 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,
},
{
// A call carried at its billed figure (no usable usage) makes the row's quote partly old money,
// and the row must SAY so: the amount is right either way, the disclosure is what the mark is.
name: "a call that could not be re-priced marks the row as quoted from history",
cs: store.ChunkStatus{Chapter: 2, ChunkIdx: 0, Stage: "draft", CostUSD: 0.07},
wantUSD: 0.16,
wantFromHistory: true,
},
{
// A call priced across a model move makes the row an upper estimate, and the row says so.
name: "a call priced at a moved model marks the row as moved",
cs: store.ChunkStatus{Chapter: 3, ChunkIdx: 0, Stage: "draft", CostUSD: 0.01},
wantUSD: 0.10,
wantMoved: true,
},
{
// A free call inside a PAID generation must travel with it: the row's money is the hop's alone,
// so a walk that stops on money drops the free primary and the price it now carries. Quoting
// only the hop is half of what the re-run buys — the silent under-quote D39.150 forbids.
name: "a free call inside a paid generation is priced, not dropped",
cs: store.ChunkStatus{Chapter: 5, ChunkIdx: 0, Stage: "draft", CostUSD: 0.01},
wantUSD: 0.03,
wantMoved: true,
},
{
// The revert fallback quotes the BILLED amount, which is not priced at any model's current
// table — so the routing caveat does not describe it and only the old-money one does.
name: "a reverted row is quoted as billed and claims no model move",
cs: store.ChunkStatus{Chapter: 3, ChunkIdx: 0, Stage: "draft", CostUSD: 0.60},
wantUSD: 0.60,
wantFromHistory: true,
},
{
// A $0 row's generation is its trailing $0 calls, and the walk stops at the older PAID one: priced
// at the model the stage resolves to now, it is money the run WILL spend and the row says so.
name: "a $0 row whose stage moved to a paid model is priced at that model",
cs: store.ChunkStatus{Chapter: 4, ChunkIdx: 0, Stage: "draft", CostUSD: 0},
wantUSD: 0.04,
wantMoved: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
usd, fromHistory, moved := rp.usd(tc.cs)
if !nearUSD(usd, tc.wantUSD) || fromHistory != tc.wantFromHistory || moved != tc.wantMoved {
t.Fatalf("usd = ($%.6f, fromHistory=%v, moved=%v), want ($%.6f, fromHistory=%v, moved=%v)",
usd, fromHistory, moved, tc.wantUSD, tc.wantFromHistory, tc.wantMoved)
}
})
}
}
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)
}
// ⚠ AND THE SAME BOTH WAYS FOR THE MODEL-MOVE CAVEAT. A clause that prints at zero puts «0 of the 4
// unit(s) were bought from a model their stage no longer resolves to» into EVERY consent text, which
// is a caveat about nothing — and the reader who learns to skip it skips it when it means something.
// The old-money clause has had this negative pin since it was written; this one had none anywhere.
if strings.Contains(clean, "bought from a model") {
t.Errorf("no unit was priced across a model move, so the caveat must not appear at all; got: %s", clean)
}
moved := projectionBasis(RebillProjection{Rows: 4, ModelMovedRows: 2})
if !strings.Contains(moved, "2 of the 4 unit(s) were bought from a model") {
t.Errorf("units priced across a model move must be disclosed with their count; got: %s", moved)
}
if strings.Contains(moved, "originally billed") {
t.Errorf("nothing here was carried at its billed amount; the old-money caveat must stay silent: %s", moved)
}
}
// 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)
}
}