textmachine/backend/internal/pipeline/reprice.go

135 lines
6.9 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.
//
// NOT projected (like the content axis in rebill.go): ROUTING. A row is priced by the model that
// ANSWERED, not by the one its stage resolves to now — predicting the token mix of a model that has never
// seen these chunks is a different question, and it is why an escalation hop is priced at its own model.
// 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
}
// 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 := r.repriceCheckpoint(cu)
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})
}
return rp, nil
}
// repriceCheckpoint prices one stored call at today's table. `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.
func (r *Runner) repriceCheckpoint(cu store.CheckpointUsage) (usd float64, priced bool) {
var u llm.Usage
if err := json.Unmarshal([]byte(cu.UsageJSON), &u); err != nil {
return cu.CostUSD, false
}
if u == (llm.Usage{}) {
// No tokens to price. A genuinely $0 row (a local model, a $0 derived export checkpoint) is
// re-priced to the same $0 and is not a gap; only a row that COST something is. The test is on
// the whole struct rather than on selected fields, so a new usage axis cannot make it stale.
if cu.CostUSD == 0 {
return 0, true
}
return cu.CostUSD, false
}
return ledger.CostUSD(r.Pricer.PriceForResponse(cu.ModelRequested, cu.ModelActual), u), true
}
// 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.
//
// 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 bool) {
calls := rp.cells[chunkKey{cs.Chapter, cs.ChunkIdx}][cs.Stage]
var accounted float64
for i := len(calls) - 1; i >= 0 && accounted < cs.CostUSD-residueEpsilonUSD; i-- {
accounted += calls[i].then
usd += calls[i].now
if !calls[i].priced {
fromHistory = true
}
}
if accounted > cs.CostUSD+residueEpsilonUSD {
return cs.CostUSD, true // the newest calls are not this row's — see the revert case above
}
if residue := cs.CostUSD - accounted; residue > residueEpsilonUSD {
usd += residue
fromHistory = true
}
return usd, fromHistory
}
// 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