354 lines
18 KiB
Go
354 lines
18 KiB
Go
// 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
|
||
}
|