// Package pricing turns chapters into money and back. // // The user chooses a ceiling in CHAPTERS (D39.110 §2a) and the engine is stopped by a ceiling in // DOLLARS, so something has to convert between them. That something is the platform, and the rate // is a constant an operator sets — NOT a figure read out of the engine, because no such engine // surface exists: `tmctl` has no "what would this book cost" command, and the estimate the engine // does compute lives inside a run, per call, after the run has started. // // The conversion never crosses the API boundary in any direction: the contract carries chapters and // a percentage, never a sum (D39.84, CeilingBounds). package pricing import ( "fmt" "textmachine/platform/internal/money" ) // DefaultPerChapter is the beta's estimate of what one chapter costs to translate. // // Provenance, so the number is auditable rather than folkloric: exp08 v2 (docs/experiments/ // 08-cost-model-v2.md) measures the ratified standard mix — cheap draft plus editor — at $10.94 for // the zh→ru webnovel of 500 chapters, i.e. $0.0219 a chapter; that document's own revision banner // (D30.4, bilingual editor) raises the standard mix by 15-25%, giving $0.0252-$0.0274. Rounded UP to // $0.03 for one reason: a rate set too LOW lets a user pick more chapters than their money covers, // and the run then halts on the ceiling partway through, which is the outcome the scale exists to // prevent. Too high only shortens the scale. // // It is an ESTIMATE and it is wrong for any particular book — chapters differ in length, and the // webnovel it comes from is the most expensive of the three books measured. Nothing depends on it // being right: the money is protected by the hold and by the ceiling the engine enforces itself // (D39.84/D39.100), and this rate only decides how long the scale is. const DefaultPerChapter money.MicroUSD = 30_000 // $0.03 // Model converts between chapters and money. type Model struct { // PerChapter is the rate. Zero is refused at construction rather than defaulted silently: a zero // rate makes every ceiling free and every scale infinite. PerChapter money.MicroUSD } // New validates a rate. func New(perChapter money.MicroUSD) (Model, error) { if perChapter <= 0 { return Model{}, fmt.Errorf("pricing: the per-chapter rate must be positive, got %d micro-USD", perChapter) } return Model{PerChapter: perChapter}, nil } // Ceiling is the money a ceiling of n chapters is worth: what the platform holds before the run and // what the engine is told to stop at. func (m Model) Ceiling(chapters int) money.MicroUSD { if chapters <= 0 { return 0 } return money.MicroUSD(chapters) * m.PerChapter } // Bounds is the run-ceiling scale, in chapters, exactly as CeilingBounds carries it. type Bounds struct { Min int Max int Default int } // Scale computes the bounds of the ceiling scale. // // balance is the account's balance AS IT IS. It is not reduced by open holds and must not be: a hold // is a debit at the moment it is taken (credits.go, appendLedger moves the cached balance with the // ledger row), so the balance already excludes them and subtracting them again halves the scale // (D39.115 §2a — the correction of D39.110 §2в). // // chaptersLeft is what remains untranslated in the book. The maximum is clamped by BOTH, and the // client is forbidden to clamp again. func (m Model) Scale(balance money.MicroUSD, chaptersLeft int) Bounds { if balance < 0 { balance = 0 } if chaptersLeft < 0 { chaptersLeft = 0 } affordable := int(balance / m.PerChapter) maxCh := min(affordable, chaptersLeft) if maxCh <= 0 { // Zero is legal and means "no run can start at all"; the client shows the exhausted state // instead of a scale, and default is zero only in this case. return Bounds{Min: 1, Max: 0, Default: 0} } // The pre-selected value sits at the TOP of the scale. It is a product policy the platform owns // (D39.115 §4), and it is chosen this way because the ordinary intent behind starting a // translation is "translate the book": when the balance covers the book, the top IS the book. // ⚠ The trade-off, stated rather than hidden: when the book is larger than the balance, the top // of the scale reserves the WHOLE balance against this one book, and a second book then cannot // be started until this run settles (D39.110 §2в). The alternative is some fraction of the // maximum, and which fraction is a product question, not an engineering one — raised for the // owner in the zone journal rather than answered here. return Bounds{Min: 1, Max: maxCh, Default: maxCh} }