textmachine/platform/internal/pricing/pricing.go

98 lines
4.5 KiB
Go

// 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}
}