textmachine/backend/internal/pipeline/reprice.go

251 lines
15 KiB
Go

package pipeline
import (
"encoding/json"
"fmt"
"textmachine/backend/internal/ledger"
"textmachine/backend/internal/llm"
"textmachine/backend/internal/store"
)
// reprice.go: what already-billed work would cost IF BOUGHT AGAIN TODAY (row 181). A projection of
// FUTURE spend that adds up past `cost_usd` quotes a price list that no longer exists — DeepSeek's table
// moved 16.08.2026 (D39.137) — while reserve and settle price with the current one (stagerun.go).
//
// Scope: only spend that has NOT happened — the re-payment amount, its consent threshold and
// projected_book_usd. Committed/reserved and the per-chapter passport costs are historical fact.
//
// The recorded TOKENS are re-priced through settle's own seam (ledger.CostUSD + Pricer.PriceForResponse)
// rather than re-estimated with ledger.EstimateUSD: the estimate reserves the whole max_tokens budget and
// would overshoot by multiples, it needs rendered messages the read path deliberately does not have, and
// going through the settle seam is what keeps the quote and the booking from drifting apart.
//
// ROUTING IS PROJECTED, AND IT ROUNDS UP (D39.150 п.1). A call whose usage is on file is priced by the
// model its stage resolves to TODAY — the current escalate_to for a hop call, the stage's own model
// otherwise — and never below what the model that ANSWERED would charge for the same tokens: the dearer of
// the two. The direction is the whole contract: a stage that moved to a dearer model must not be quoted at
// the retired model's table ($0.003640 for $0.036400 of work is the acceptance's measurement of exactly
// that), and quoting the dearer price when the stage moved to a CHEAPER model is the safe error, which is
// why the answering model stays a floor.
//
// What stays unknowable is the TOKEN COUNT a model that has never seen these chunks would spend, and no
// stored fact can answer it; a call with no usable usage on file cannot be re-priced at all and is carried
// at its billed figure (usd). So the figure is an ESTIMATE that errs upward where the tokens are known, not
// a re-pricing of known tokens: the consent text says so and names how many units each caveat holds for
// (projectionBasis), and nothing on this path promises accuracy.
// repricedCall is one stored provider call: what it was billed, and what the same tokens cost today.
type repricedCall struct {
then float64
now float64
// priced is false when the checkpoint carries no usable token count, and `now` is the billed amount.
priced bool
// moved is true when the stage this call belongs to resolves today to a model other than the one this
// call was SENT to, so `now` is the dearer of two prices for a token count only one of them produced.
// The comparison is against the requested slug, not the answering one, because the re-run will request
// what the stage resolves to: a provider that canonicalises its slug in the response (`gpt-5-mini` →
// `gpt-5-mini-2025-08-07`) has not re-routed anything, and reading the answer here would report every
// call on such a provider as a routing move.
moved bool
}
// repricer answers "what would this stored row cost today" for every disposition row of a book. A cell
// holds its calls OLDEST FIRST, because which of them still back the row is decided from the newest end
// (see usd).
type repricer struct {
cells map[chunkKey]map[string][]repricedCall
}
// newRepricer reads the book's checkpoint usage once. It is EAGER rather than lazy on purpose: both
// callers (the consent gate and the status read-model) need it for the amounts they publish, and a
// lazily-loading variant would have to smuggle a store error out of an arithmetic helper.
func (r *Runner) newRepricer() (*repricer, error) {
rows, err := r.Store.CheckpointUsageForBook(r.Book.BookID)
if err != nil {
return nil, fmt.Errorf("pipeline: read checkpoint usage for the re-pricing of already-billed work: %w", err)
}
rp := &repricer{cells: make(map[chunkKey]map[string][]repricedCall)}
for _, cu := range rows {
usd, priced, moved := r.repriceCheckpoint(cu)
// The cell is the POSITION — chapter AND chunk. A chapter is several chunks whenever the cut says so,
// and a cell keyed by chapter alone pools their calls, so the newest-first walk in usd() eats the
// neighbour's money. Pinned by TestEveryChunkOfAChapterIsRePricedFromItsOwnCalls and catalogued
// (cmd/tmmutate, FC1-cell-key).
key := chunkKey{cu.Chapter, cu.ChunkIdx}
byStage := rp.cells[key]
if byStage == nil {
byStage = map[string][]repricedCall{}
rp.cells[key] = byStage
}
byStage[cu.Stage] = append(byStage[cu.Stage], repricedCall{then: cu.CostUSD, now: usd, priced: priced, moved: moved})
}
return rp, nil
}
// currentModelFor is the model a stored call's stage would send the same request to TODAY: the stage's
// resolved escalate_to for a hop call, its resolved model otherwise. ok is false for a stage the current
// pipeline does not run — such a row is never re-bought (rebill.go), so it keeps the answering model's
// price and no routing question arises.
func (r *Runner) currentModelFor(stage string, escalation bool) (model string, ok bool) {
for _, st := range r.Pipeline.Stages {
if st.Name != stage {
continue
}
if escalation && st.ResolvedHop != "" {
return st.ResolvedHop, true
}
return st.ResolvedModel, st.ResolvedModel != ""
}
return "", false
}
// repriceCheckpoint prices one stored call at today's table, at the model its stage resolves to TODAY and
// never below the answering model's price (the file comment). `priced` is false when the checkpoint
// carries no usable token count and the historical amount is returned instead — the real case being the
// billed-decode-failure checkpoint, which settles the RESERVATION ESTIMATE against a `{}` usage
// (stagerun.go): re-pricing that to $0 would quietly delete money from the consent number. `moved` is true
// when the stage resolves to a slug other than the one this call REQUESTED (see repricedCall), so the
// caller can say how much of its figure is an upper estimate.
func (r *Runner) repriceCheckpoint(cu store.CheckpointUsage) (usd float64, priced, moved bool) {
var u llm.Usage
if err := json.Unmarshal([]byte(cu.UsageJSON), &u); err != nil {
return cu.CostUSD, false, false
}
if u == (llm.Usage{}) {
// No tokens to price. A $0 row with no usage is the derived checkpoint (a repair or export
// projection, which reaches no provider and re-derives free): re-pricing it to the same $0 is the
// answer, not a gap. The test is on the whole struct rather than on selected fields, so a new usage
// axis cannot make it stale.
//
// ⚠ UNLESS THE STAGE HAS MOVED. Then the row is not provably derived — a provider that returned no
// usage block leaves the same shape — and its re-run goes to a model whose price is not $0, while
// there are no tokens to price it with. Publishing a bare $0 there is the silent under-quote this
// file exists to remove, so the amount stays what is known and the row is reported as one that
// could NOT be re-priced (projectionBasis names it).
if cu.CostUSD == 0 {
today, ok := r.currentModelFor(cu.Stage, cu.Escalation)
return 0, !ok || today == cu.ModelRequested, false
}
return cu.CostUSD, false, false
}
// The floor: these tokens at the answering model's price on today's table — the same
// PriceForResponse ordering settle used, so a canonicalised slug still finds its price.
floorPrice, _ := r.Pricer.PriceForResponse(cu.ModelRequested, cu.ModelActual)
usd = ledger.CostUSD(floorPrice, u)
today, ok := r.currentModelFor(cu.Stage, cu.Escalation)
if !ok {
return usd, true, false
}
// The model that WILL answer sets the other bound, whether or not the stage moved: a provider that
// answered a request for X under a slug Y the table lists cheaper would otherwise price the re-run —
// which asks for X again — at Y's table. The dearer of the two is the answer: the token count belongs
// to the model that answered, the price to the one that will, and neither may pull the figure below
// the other (D39.150 п.1: round up when it cannot be known).
if at := ledger.CostUSD(r.Pricer.PriceFor(today), u); at > usd {
usd = at
}
return usd, true, today != cu.ModelRequested
}
// usd is what re-buying this disposition row would cost at the current price table; `fromHistory` says
// the answer is partly the amount it was billed at instead, `moved` that some of it is priced at a model
// other than the one that produced the tokens (the file comment: an upper estimate, not a re-pricing).
//
// GENERATION MEMBERSHIP, and why it has to be DERIVED (this is an inference, not an identity — the one
// place in here worth distrusting). Checkpoints are append-only for the life of the book and carry no
// snapshot: re-buying a unit, or editing the source under it, leaves the old call on file beside the new
// one. chunk_status.cost_usd is the opposite — OVERWRITTEN each run with the CURRENT generation's cost.
// So pricing every call of a position answers "what has this position ever cost" in place of the question
// the operator is consenting to. The row's own money is the only per-generation authority in the store,
// so membership is taken from it: walk the calls NEWEST FIRST, keeping them while they fit inside
// cs.CostUSD.
//
// TWO PRECONDITIONS, and what happens when each fails:
// - the generation's calls SUM to the row's cost. Short of it means calls were lost (a restore, a
// legacy row) — the remainder is carried at its billed value, the conservative direction and the
// answer the projection gave before re-pricing existed. Not a routine branch: the one path that
// deletes checkpoints, ResetChunkStages, deletes the disposition row with them in one transaction.
// - they are the NEWEST calls of the cell. A config REVERT breaks this: the re-run addresses an old
// request_hash, the settle is a no-op (ON CONFLICT DO NOTHING, store/ledger.go) and the row is
// rewritten with an OLDER call's cost while newer, superseded ones stay on file. The walk then
// overshoots — which is detectable, so it is detected, and the row falls back to its billed amount
// marked NOT re-priced. That quotes stale money, but it quotes it out loud, which is the whole
// point of the row this file closes.
//
// Recovering the revert case EXACTLY needs a generation marker on `checkpoints` (they carry neither
// snapshot_id nor run id) — a money-table schema change, and a question for ratification rather than a
// third patch here.
func (rp *repricer) usd(cs store.ChunkStatus) (usd float64, fromHistory, moved bool) {
calls := rp.cells[chunkKey{cs.Chapter, cs.ChunkIdx}][cs.Stage]
var accounted float64
// A CALL THAT COST NOTHING NEVER STOPS THE WALK, and this is the whole of how a $0 model is handled.
// Membership is read off money, and a free call adds none: it can neither complete the row's cost nor
// overshoot it, so stopping on one would drop a call the re-run WILL make — and with it the price that
// call now carries. That is a silent under-quote, one function below the pricing that answered
// correctly, and it bites in two shapes: a stage run entirely on a $0 model (the row itself is $0), and
// a $0 primary whose PAID escalation hop alone accounts for the row (a local model with a cloud
// fallback). Both quote what the re-run costs only if the free calls travel with the paid ones.
//
// So: a free call is taken whenever the walk reaches it, and the walk stops at the first PAID call once
// the row's money is accounted for.
//
// ⚠ WHAT THAT COSTS, said out loud: a free call is not provably of THIS generation either. A $0 stage
// leaves cs.CostUSD == 0 after every run, so N free generations at one position are indistinguishable
// and all N are counted; a free call sitting at the end of an OLDER generation is taken along with this
// one's. The quote is then larger than one re-pass buys — an OVER-estimate, which is the ratified
// direction (D39.150 п.1: round up when it cannot be known) and what the consent sentence already
// declares («an ESTIMATE that errs upward»). Making it exact needs a generation marker on
// `checkpoints`, the same schema question the docstring above defers.
taken := 0
for i := len(calls) - 1; i >= 0; i-- {
free := calls[i].then <= residueEpsilonUSD
if accounted >= cs.CostUSD-residueEpsilonUSD && !free {
break // the row's money is accounted for, and this call is somebody else's
}
accounted += calls[i].then
usd += calls[i].now
taken++
// The disclosure travels WITH the amount: a call carried at its billed figure makes the row's
// quote partly old money, and the text the operator consents to names that (projectionBasis).
// Pinned on the production path by TestABilledDecodeRowIsDisclosedAsOldMoneyEndToEnd and
// catalogued (cmd/tmmutate, FC1-unpriced-not-disclosed).
if !calls[i].priced {
fromHistory = true
}
if calls[i].moved {
moved = true
}
}
if accounted > cs.CostUSD+residueEpsilonUSD {
// The newest calls are not this row's — see the revert case above — so the row is quoted at what it
// was BILLED. That figure is not priced at any model's current table, which is exactly what
// ModelMovedRows counts, so the routing caveat does not apply to it and `moved` is dropped with the
// calls it came from. What the reader is owed here is the OTHER caveat, and fromHistory carries it.
return cs.CostUSD, true, false
}
if residue := cs.CostUSD - accounted; residue > residueEpsilonUSD {
usd += residue
fromHistory = true
}
// ⚠ A $0 ROW WHOSE NEWEST CALL IS SOMEBODY ELSE'S is quoted at nothing, and nothing is not an answer.
// It is the revert case in the one shape the overshoot test above cannot see: the row's money is $0, so
// no walk can overshoot it, and the calls on file all belong to older generations. Publishing a bare $0
// for it is the same silent under-quote in a quieter place, so the amount stays what is known and the
// row is reported as one whose generation could NOT be established.
//
// The two guards say what the branch is NOT about. A row with no calls at all is genuinely free and has
// nothing to disclose — there is no generation to fail to establish. A `skipped` row never reached a
// provider, and the guard for it is DEFENSIVE rather than load-bearing: projectRebill drops skipped rows
// before this function sees them (rebill.go) and projectBookUSD ignores the flag, so today it changes
// only what a hand-built row gets. It stays because a future caller passing dispositions through would
// otherwise start disclosing rows that cost nothing by construction.
if taken == 0 && len(calls) > 0 && cs.Disposition != string(DispSkipped) {
fromHistory = true
}
return usd, fromHistory, moved
}
// residueEpsilonUSD is far below any amount this engine prints (%.6f): it separates money a row really
// lost from the float noise of adding the same terms in a different order.
const residueEpsilonUSD = 1e-9