98 lines
4.5 KiB
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}
|
|
}
|