textmachine/platform/internal/pricing/pricing.go

354 lines
18 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 pricing answers the two questions the order form asks: what will this order cost, and how
// much money does the platform have to hold for it.
//
// ⛔ THERE IS NO PER-CHAPTER RATE HERE ANY MORE, and its absence is the point of the package rather
// than a tidy-up. Until 05.09 a chapter was worth a CONSTANT — `DefaultPerChapter`, $0.03, provenance
// carefully documented and measured to be 4.47× too low the moment DeepSeek re-priced (D39.179 §1) —
// and everything downstream inherited its error. A run's new headroom was `chaptersLeft × $0.03`, so
// the LAST chapters of any book were bought with less money than one editor call reserves (~$0.07):
// the run died in ten seconds having moved nothing, and it did so at any balance. That is PD-440,
// and no calibration of a constant could remove it, because the constant shrinks with the book while
// the cost of one indivisible call does not.
//
// What replaced it is the engine's own projection, published per book and per chapter since landing
// 81a89e9 (act D39.206): `expected_usd` — what the work will be BILLED, computed with the settlement
// formula over the pair's own fertility calibration — and `step_max_usd` — the largest single
// INDIVISIBLE reservation, computed with the reservation formula at worst case. Two numbers, two
// arithmetics, and the engine states in its own file why publishing one for both is what produced
// «this book cannot be translated at any price».
//
// A book the engine could not price is REFUSED rather than sold at a guess. That refusal is what
// makes the deletion of the constant safe: there is now exactly one source for what a book costs,
// and when it is silent the platform says so instead of inventing the number that broke.
//
// PAIR-AGNOSTIC BY CONSTRUCTION. Nothing in this file knows a language, a book or a model: it adds
// up figures the engine derived from the pair's own data. A pair that is not in this repository is
// priced by these same lines (общность §0.1).
package pricing
import (
"fmt"
"textmachine/platform/internal/money"
)
// DefaultHoldFactorPercent is `k`: the cushion the hold carries over the engine's expected bill,
// as a percentage.
//
// ⚠⚠ IT IS DERIVED AND NOT MEASURED, and it is labelled so here because a number that reads like a
// measurement while being an argument is the class this project keeps paying for (D39.202 §2в). The
// derivation, so a reader can re-do it rather than trust it:
//
// - `expected_usd` EXCLUDES retries, escalation, repair and additive reasoning by construction
// (backend/internal/pipeline/priceprojection.go, projectUnit). It is therefore a LOWER bound of
// the bill the moment anything regenerates, and a factor of exactly 1 would latch a run on the
// first regeneration of every purchase.
// - The only live figures there are: two chapters of one paid run (31.08) came in at $0.093 and
// $0.151 — a ratio of 1.62. ⚠ TWO POINTS, NOT A DISTRIBUTION. It bounds the guess; it does not
// measure a spread, and the next reader must not quote it as one. The measurement that replaces
// this constant is unified backlog row 281.
// - The DIRECTION of the error is ratified and it changed on 05.09 with D39.206: a money stop is no
// longer a crash. A refusal now LATCHES the wave — what was admitted finishes, the run pauses and
// says how much was missing — so a hold that is too small costs the buyer one top-up, while a
// hold that is too large freezes credit they cannot spend elsewhere. Err LOW.
//
// 125 is above 1 (ordinary regeneration does not latch every purchase) and well below the 1.62 seen
// (the error stays on the low side, as ratified).
const DefaultHoldFactorPercent = 125
// Model is the platform's half of the arithmetic: one operator-set cushion, and nothing else.
type Model struct {
// HoldFactorPercent is `k` as an integer percentage — CONFIGURATION and not a literal in the
// logic, because the measurement of row 281 must arrive as data rather than as a code change.
HoldFactorPercent int
}
// New validates the cushion.
func New(holdFactorPercent int) (Model, error) {
if holdFactorPercent < 100 {
// Below the engine's own expected bill is not a cushion, it is a discount: every purchase
// would latch before it delivered what it quoted.
return Model{}, fmt.Errorf("pricing: the hold factor must be at least 100%%, got %d", holdFactorPercent)
}
return Model{HoldFactorPercent: holdFactorPercent}, nil
}
// maxExpected bounds the projection the arithmetic will act on: one million dollars, which is four
// orders of magnitude above the most expensive book anybody has measured ($10.94 for a 500-chapter
// webnovel at the standard mix) and far below where `expected × 125` leaves int64. A figure above it
// did not come from a book.
const maxExpected = money.MicroUSD(1_000_000) * money.PerUSD
// Unit is one output unit of a book still to be delivered — the granularity a CHARACTER order is
// both expressed against and PRICED from.
type Unit struct {
// ID is the boundary a character order is stored as: a unit id carries the cut in its own bytes.
ID string
// Expected is this unit's own bill, and SourceChars its own text.
Expected money.MicroUSD
SourceChars int64
}
// Chapter is one chapter of a book as an order is priced against it: what the engine expects it to
// cost, how much text is in it, and how many output units it ships.
type Chapter struct {
// ID is the chapter's identity — what an order phrased in chapters is ANCHORED on. Carried
// through the arithmetic so the quote and the stored order name the same boundary by the same
// fact; the ordinal beside it is a label (migration 00033).
ID string
// Number is the DISPLAY ordinal — the label an order is phrased in («перевести до главы N»).
Number int
// Units is how many output units this chapter ships. It is what `translate --max-units` counts.
Units int
Expected money.MicroUSD
SourceChars int64
}
// Book is what is left of a book to buy, in the order the engine will deliver it.
type Book struct {
// StepMax is the largest single reservation any one call of this book can ask for. It is the
// FLOOR of every hold: a ceiling below it admits nothing at all, whatever else is true.
StepMax money.MicroUSD
// BookOnce is the BOUND on the book-level passes — the terminology consolidation and its
// classifier, which read the whole book's drafts once. It is flat: $2.00 on the shipped arm
// whatever the book's length.
BookOnce money.MicroUSD
// Remaining are the chapters still to be delivered, ascending by Number.
Remaining []Chapter
}
// Verdicts, exactly as the contract's OrderOptions carries them.
const (
// VerdictCoversAll — the balance covers the whole of what is left of this book.
VerdictCoversAll = "covers_all"
// VerdictCoversPart — it covers some chapters but not all of them.
VerdictCoversPart = "covers_part"
// VerdictCoversNone — it does not cover even one chapter, so no run can start.
VerdictCoversNone = "covers_none"
)
// Quote is one possible order, priced. It is what the buyer is shown BEFORE the click, and the
// numbers in it are what the admission then acts on.
type Quote struct {
// Chapters is how many of the remaining chapters this order buys; 0 with Units 0 means the order
// buys nothing (there is nothing left, or nothing affordable).
Chapters int
// ThroughChapterID and ThroughChapter are the last chapter bought: its IDENTITY, which is what
// the order is stored as, and its ordinal, which is the label beside it. Both empty/zero when the
// order is the whole remainder — «the whole book» must not freeze into any boundary at all.
ThroughChapterID string
ThroughChapter int
// Units is how many output units the order buys — the figure `--max-units` is derived from.
Units int
// UnitShaped says the order does not close whole chapters: it stops inside one. Only a CHARACTER
// order can, and it is the reason such a run needs a bar counted in units — in chapters it would
// read `0/N` for its whole life, because a chapter counts only once every unit in it is done.
UnitShaped bool
// Expected is what the engine expects the ordered work to be BILLED. It is the honest half of the
// pair a buyer is shown («ожидаемо ≈ …, зарезервируем до …»).
Expected money.MicroUSD
// Hold is what the account reserves — the other half of that pair, and the number that becomes
// the engine's ceiling increment.
Hold money.MicroUSD
// BondFunded says whether Hold has room for the book-level passes on top of the ordered work.
//
// ⚠ FALSE IS NOT A FAILURE AND IT MUST NOT BE SILENT. The book-level passes feed the memory bank,
// and the bank is the mechanism behind consistency of terms across a whole book — the owner's
// first priority, stated as «100% консистентность, никаких разнопереведённых терминов» (D39.198).
// They DEGRADE rather than halt when a ceiling refuses them (the engine says so itself:
// priceprojection.go, stepMaxForUnit — «a ceiling too small for them costs quality, not
// progress»), which is exactly what makes the failure invisible: the book arrives, and only its
// terms wander. So the fact travels to the buyer BEFORE the click. Ratified 05.09 by the
// orchestrator on this pack's own finding.
BondFunded bool
}
// Hold is the money one order reserves, and the whole formula lives here.
//
// hold = k × expected + stepMax [+ bookOnce when the balance carries it]
//
// ⛔ THE `+ stepMax` TERM IS ADDITIVE AND NOT A FLOOR, and that is what removes the last of PD-440
// rather than merely widening it. The engine compares its ceiling against the book's CUMULATIVE
// committed + reserved on EVERY reservation, so as an order is delivered the room left over shrinks
// by what has been billed. The LAST call of the order therefore needs one whole reservation of room
// ON TOP of everything the order has already spent — and `max(k × expected, stepMax)` does not give
// it that: on any order whose bill exceeds one reservation the maximum is the bill, the last call is
// refused, and the book stops one call short of what was bought. Additive, the room for that call is
// there by construction at every point of the order.
//
// ⛔ `bookOnce` IS NOT IN THE BASE, and the reason is that it is a BOUND rather than a forecast and a
// FLAT one — $2.00 on the shipped arm for a book of three chapters and for a book of three hundred
// alike (backend/configs/pipeline-c1.yaml: gates.terminology.budget_usd $1.00 plus
// classify_budget_usd $1.00). Folded into the base it would put a two-dollar threshold under every
// purchase, and a five-chapter book expected to cost twenty-five cents would be unbuyable — PD-440's
// wall again, wearing different clothes. So it is added only when the balance carries it on top of
// the order, and when it is not added the caller is told, because a silently unfunded consistency
// pass is a refusal that looks exactly like normal work (D39.202 §3).
func (m Model) Hold(expected, stepMax, bookOnce, balance money.MicroUSD) (hold money.MicroUSD, bondFunded bool) {
if expected <= 0 {
// Nothing left to buy. The pair «expected 0, reserve one whole reservation plus a flat
// book-level bound» is arithmetic rather than an offer, and it reads on a screen as a price
// for a book that is finished. Zero across the board says what is true.
return 0, true
}
// ⚠ THE MULTIPLICATION IS BOUNDED BEFORE IT HAPPENS, not after. micro-USD is an int64 and the
// cushion multiplies BEFORE it divides — deliberately, because dividing first throws away the
// cents — so an absurd projection ($7.4·10¹⁰ and up, which `money.ParseUSD` will accept) would
// wrap and hand back a hold SMALLER than the bill it is supposed to cover. Not a wrap into the
// negative, which the ledger would refuse: a plausible, small, wrong number. No book reaches it;
// a projection read out of a document that is not a manifest could.
if expected > maxExpected {
expected = maxExpected
}
base := expected*money.MicroUSD(m.HoldFactorPercent)/100 + stepMax
if bookOnce <= 0 {
// The deployment runs no book-level pass at all, so there is nothing to fund and nothing to
// warn about. `true` is the honest answer: no consistency work is going unpaid.
return base, true
}
if base+bookOnce <= balance {
return base + bookOnce, true
}
return base, false
}
// Quote prices an order of the first `chapters` remaining chapters. `chapters` at or above what is
// left — and zero — means the whole remainder, which is the default order (D39.196 §1: «не тронута
// ни одна ручка ⇒ заказ = вся книга»).
func (m Model) Quote(b Book, balance money.MicroUSD, chapters int) Quote {
if chapters <= 0 || chapters > len(b.Remaining) {
chapters = len(b.Remaining)
}
q := Quote{Chapters: chapters}
for _, c := range b.Remaining[:chapters] {
q.Expected += c.Expected
q.Units += c.Units
}
if chapters > 0 && chapters < len(b.Remaining) {
// A PARTIAL order names the chapter it stops at; the whole remainder deliberately does not, so
// that «the whole book» keeps meaning the whole book after a re-cut moves the numbers.
q.ThroughChapterID = b.Remaining[chapters-1].ID
q.ThroughChapter = b.Remaining[chapters-1].Number
}
q.Hold, q.BondFunded = m.Hold(q.Expected, b.StepMax, b.BookOnce, balance)
return q
}
// Affordable is the largest number of remaining chapters this balance can buy.
//
// It WALKS the chapters rather than dividing by an average, because there is no average: the whole
// point of reading the engine's projection is that a chapter of a dense Han source and a chapter of
// an alphabetic one are very different money, and so are two chapters of one book.
//
// ⚠ The walk carries a RUNNING SUM rather than re-quoting each prefix. Re-quoting re-adds the
// prefix every step, which is quadratic in the chapter count — and this runs on the options read,
// which a client polls, over books of a couple of thousand chapters. The arithmetic is the same one
// Quote performs; what is not repeated is the summing.
func (m Model) Affordable(b Book, balance money.MicroUSD) int {
n, expected := 0, money.MicroUSD(0)
for i, c := range b.Remaining {
expected += c.Expected
if hold, _ := m.Hold(expected, b.StepMax, b.BookOnce, balance); hold > balance {
break
}
n = i + 1
}
return n
}
// Options is the whole answer of the order form, before the click.
type Options struct {
// ChaptersLeft is what is still untranslated.
ChaptersLeft int
// AffordableChapters is how many of those this balance can buy.
AffordableChapters int
// Verdict answers the buyer's real question — «хватает на всю книгу или нет».
Verdict string
// Whole is the quote for the default order: everything that is left.
Whole Quote
// Balance is the account's money as it is (D39.115 §2a — holds are already debited from it).
Balance money.MicroUSD
// MinHold is the smallest hold that can buy anything at all: one indivisible reservation. It is
// the minimum of the money slider, so the slider cannot express an impossible order (row 276).
MinHold money.MicroUSD
}
// Order computes the order form for a book and a balance.
func (m Model) Order(b Book, balance money.MicroUSD) Options {
out := Options{
ChaptersLeft: len(b.Remaining),
AffordableChapters: m.Affordable(b, balance),
Whole: m.Quote(b, balance, 0),
Balance: balance,
MinHold: b.StepMax,
}
switch {
case out.ChaptersLeft > 0 && out.AffordableChapters >= out.ChaptersLeft:
out.Verdict = VerdictCoversAll
case out.AffordableChapters > 0:
out.Verdict = VerdictCoversPart
default:
// Includes the book with nothing left to translate: there is no order it can carry, and
// `chapters_left: 0` is what tells the two apart on the wire.
out.Verdict = VerdictCoversNone
}
return out
}
// UnitsFor is the shortest prefix of `units` that reaches `want` characters — the CHARACTER order,
// which is the only partial order a book with no chapter structure can carry (D39.196 §1: знаки,
// когда книга не распозналась).
//
// The answer is a UNIT count, because units are the granularity the engine ships and the granularity
// `--max-units` counts; characters are what the buyer expresses and are never a stopping point of
// their own.
//
// Rounds UP to a whole unit — a buyer who asks for ten thousand characters gets the unit that
// contains the ten-thousandth, not one short of it — and asking for more than the book has buys the
// book.
//
// ⚠ A UNIT OF UNKNOWN SIZE DOES NOT ADVANCE THE SUM, and that is the difference between «this order
// covers everything» and «this build could not read the sizes». Zero sizes make every prefix fall
// short, so the loop would run off the end and quietly answer «the whole book» — a partial order
// silently becoming a total one. The caller is refused instead: sizes are witnessed before a book is
// sold at all (ingest.Priced), and this is the second line of that defence rather than a repeat.
func UnitsFor(units []Unit, want int64) (int, bool) {
if want <= 0 || len(units) == 0 {
return 0, false
}
sum := int64(0)
for i, u := range units {
if u.SourceChars <= 0 {
return 0, false
}
sum += u.SourceChars
if sum >= want {
return i + 1, true
}
}
return len(units), true
}
// QuoteUnits prices an order of the first `n` remaining UNITS — the character order, resolved.
//
// ⛔ IT PRICES THE UNITS AND NOT THE CHAPTERS THEY FALL IN, which the first edition of this path got
// backwards. Quoting a unit order at the price of every chapter it touches makes the order
// meaningless exactly where it is the only one available: a book with no chapter structure is ONE
// chapter, so buying a thousand characters of it reserved the whole book. Measured on a five-unit
// `none` book at $0.03 a unit: the honest hold for one unit is $0.107328 and the chapter-priced one
// was $0.257328 — and a buyer holding $0.12 was refused a purchase they could afford.
//
// `chapters` is carried for the RUN's own bar, which is still counted in chapters (canon §Progress),
// and it is the coarser figure of the two: a character order can stop inside a chapter. What the
// engine is given is the unit count, which is not coarse.
func (m Model) QuoteUnits(b Book, balance money.MicroUSD, units []Unit, n, chapters int) Quote {
if n > len(units) {
n = len(units)
}
q := Quote{Chapters: max(chapters, 1), Units: n}
for _, u := range units[:n] {
q.Expected += u.Expected
}
q.Hold, q.BondFunded = m.Hold(q.Expected, b.StepMax, b.BookOnce, balance)
return q
}