Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

aging.gno

32.20 Kb · 713 lines
  1// Package aging partitions a set of unpaid obligations by how long each has
  2// been past due, and prices the resulting schedule into a single expected
  3// loss.
  4//
  5// A realm holds the obligations — invoices, loans, dues — and asks this
  6// package two questions: how is what I am owed distributed across age bands,
  7// and what should I expect not to collect. Nothing here reads the chain, holds
  8// state, or knows what an invoice is, so a realm can test a year of its own
  9// receivables without a node.
 10//
 11//	s, _ := New([]int64{30, 60, 90}, []int64{0, 1000, 2500, 5000})
 12//	rep, err := s.ReportAt(obs, now)   // four bucket totals and their sum
 13//	loss, err := s.Provision(rep)      // the expected credit loss
 14//
 15// The two steps are separable on purpose: the report is the line items a
 16// reader sees, and the provision is the one number that goes in a ledger.
 17// [Schedule.ProvisionFor] does both in one call for a caller that only wants
 18// the number.
 19//
 20// # The output is a provision, and never an allowance
 21//
 22// The textbook name for this number is "allowance for doubtful accounts", and
 23// that word is not available here. `allowance` already means a *spending*
 24// allowance across this portfolio — including [p/spendbook]'s `Allowance`
 25// type — and it means an approve/transferFrom quota across the wider
 26// ecosystem's GRC20 realms. A type named `Allowance` in a package imported
 27// beside either of those would read as the wrong thing at every call site, so
 28// the loss number is `Provision` throughout. See DISCOVERY.md § 4.
 29//
 30// # Why the floor is taken per bucket, and exactly what that costs
 31//
 32// The provision is the sum of per-bucket floors:
 33//
 34//	Provision = Σ floor(bucketᵢ × rateᵢ / RateScale)
 35//
 36// not one floor taken at the end of an exact rational total. Those are
 37// different numbers, and which one a package shipped is invisible in any test
 38// that happens to use round rates.
 39//
 40// Per-bucket is chosen because [Schedule.ProvisionByBucket] is the line-item
 41// column a reader sees next to the bucket totals, and line items that do not
 42// add up to the stated total are a bookkeeping defect regardless of which
 43// total is "more accurate". [Schedule.Provision] is *defined* as the sum of
 44// that column, so the two cannot disagree — the same discipline
 45// [p/depreciation] uses to define `Charge` in terms of `CarryingAt`.
 46//
 47// The cost of that choice is a real and measurable weakening of the obvious
 48// invariant, and it is stated here because the discovery record for this
 49// package asserted the strong form and the strong form is false:
 50//
 51//	FALSE: for a fixed unpaid set, Provision is non-decreasing in now.
 52//
 53// The counterexample is small enough to state in full. Take edges `[30]` and
 54// rates `[5000, 5000]` — one boundary, the same 50% either side — and two
 55// obligations of amount 1. While both sit in bucket 0 the buckets are
 56// `[2, 0]` and the provision is `floor(2×5000/10000) = 1`. Let one age past
 57// 30: the buckets become `[1, 1]`, each floors to `0`, and the provision is
 58// `0`. **It fell as the receivables got older**, with no payment and no change
 59// of rate. One floor at the end would have returned 1 in both states.
 60//
 61// What is true, and what [TestProvisionNearlyMonotone] asserts:
 62//
 63//   - The exact rational provision Σ bucketᵢ×rateᵢ/RateScale **is**
 64//     non-decreasing in `now`, because an obligation only ever moves to a
 65//     higher-indexed bucket and [New] requires the rates to be
 66//     non-decreasing. That is the economic claim, and it survives.
 67//   - The integer provision is non-decreasing **to within fewer than
 68//     NumBuckets units**: each bucket loses strictly less than 1 to its
 69//     floor, and only a non-empty bucket loses anything, so
 70//     `Provision(later) > Provision(earlier) - min(NumBuckets, len(obs))`.
 71//     That bound is **attained**, not merely sound, and the figure is left
 72//     to [TestTheMonotonicityBoundIsAttained] rather than restated here:
 73//     at a flat rate of `RateScale-1`, `n` obligations of amount 1 price to
 74//     `n-1` when they share a bucket and to 0 when they are one per bucket,
 75//     so the drop is exactly `min(NumBuckets, len(obs)) - 1` at every
 76//     bucket count this package allows. No slack is available to an
 77//     implementation that wanted some.
 78//
 79// The random sweep in [TestProvisionNearlyMonotone] reports a far smaller
 80// largest drop than that, and the difference is a property of the sweep
 81// rather than of the package: with randomly drawn rates the shape that loses
 82// the most to the floors essentially never comes up. Both run, and the
 83// constructed one is the one the bound is quoted from.
 84//
 85// So the provision is safe to re-run and safe to re-run often — it cannot
 86// wander upward, and it cannot fall by more than a rounding unit per bucket.
 87// It is not a monotone function, and a realm that gates on
 88// `provision >= lastProvision` will eventually be wrong by one. [TestFloorIsPerBucketNotAtTheEnd]
 89// pins the counterexample itself, so a later "fix" that quietly moves to a
 90// single floor at the end fails the suite rather than silently changing every
 91// published number.
 92//
 93// # Why the rates must be non-decreasing
 94//
 95// [New] refuses a rate schedule that falls anywhere. Two reasons, and the
 96// second is the load-bearing one:
 97//
 98//   - It is the domain claim. An older receivable is not more likely to be
 99//     collected than a younger one. A schedule that says otherwise is almost
100//     always a transposed array, which is a real and silent authoring error:
101//     `{5000, 2500, 1000, 0}` is a plausible-looking argument and prices a
102//     portfolio inside-out.
103//   - Without it the *exact rational* monotonicity above fails too, and then
104//     nothing is left. An obligation crossing an edge into a cheaper bucket
105//     reduces the exact provision, so the only invariant this package offers
106//     would be false in its strong form as well as its integer form.
107//
108// # Why a not-yet-due obligation is refused
109//
110// An obligation with `DueAt > now` has no age, and the choices are to refuse
111// it or to put it in the youngest bucket. Bucketing it would be silent and
112// would inflate every provision by whatever the youngest rate is applied to
113// money that nobody is late paying. So it is [ErrNotYetDue], following
114// `r/books`' `TestKindOfReportsInvalidRatherThanBucketing` — refused, not
115// silently bucketed.
116//
117// The "current" column of a conventional aging report is therefore the
118// caller's, and the caller already has it: on [r/invoices] it is
119// `OutstandingFrom - OverdueFrom`. This package describes the past-due tail
120// and declines to guess about the rest.
121//
122// # The degenerate two-bucket case, and the off-by-one in it
123//
124// A two-bucket schedule with edge `1` and rates `{0, RateScale}` reproduces
125// [r/invoices]' `OverdueFrom` exactly, over the obligations whose `DueAt` has
126// arrived: everything strictly past due is priced at 100% and everything else
127// at 0%.
128//
129// The edge is **1 and not 0**, which is worth the sentence because
130// DISCOVERY.md § 1 says 0 and is wrong. `isOverdue` is `now > dueHeight`,
131// strictly — an invoice at exactly its due height is due and not yet late, so
132// that invoice has age 0 and must land in the *unpriced* bucket. Edge 0 makes
133// bucket 0 empty and prices it at 100%, overstating the loss by every invoice
134// due precisely today. An off-by-one on a half-open boundary is the defect
135// class this package is most exposed to, and the discovery record shipped one
136// in the very claim that identified the consumer.
137//
138// Edge 0 is now refused outright — [ErrBadEdges], see check — so the
139// overstatement described above is no longer expressible rather than merely
140// documented. It was expressible, and accepted, until the pre-deployment audit
141// found it: AUDIT.md Y2. A paragraph naming a defect class while the validator
142// admitted an instance of it is the shape worth remembering here.
143//
144// Note also what an `Amount` is in that correspondence: `OverdueFrom` sums
145// `amount - paid`, the unpaid remainder. [Obligation.Amount] is that
146// remainder and not a face amount. A caller that passes face amounts prices
147// money it has already collected.
148//
149// # What bounds the work
150//
151// Bucket lookup is a linear scan over the edges, which is cheaper than a
152// binary search at these sizes and is the shape an auditor can check by
153// reading. [MaxBuckets] is 16, so there are at most 15 edges, and
154// [MaxObligations] is 1024: one [Schedule.ReportAt] therefore performs at
155// most 15,360 comparisons plus 1024 additions, and [Schedule.Provision]
156// performs at most 16 multiplications and divisions. Every exported method
157// re-runs `check`, which costs at most 31 further comparisons.
158//
159// Those are the figures, not an assertion that they are affordable. A realm
160// that ages several portfolios in one transaction, or does it inside a
161// `Render`, must size them against the gas it has. This package bounds the
162// work; it does not price it.
163//
164// # What this package does not claim
165//
166//   - **No novelty for partitioning time into buckets.** [p/twap] on
167//     gnoland-1 quantises an observation series into fixed-width windows and
168//     was read in full before this was written; what was not found in the
169//     searched sources is the domain model — obligations, a past-due age, a
170//     per-band loss rate, a provision. See DISCOVERY.md § 1 and § 3.
171//   - **Nothing about money.** A provision is an estimate in a report. This
172//     package never sees a coin, and a realm using it must not infer custody
173//     or a transfer from it.
174//   - **No unit of time, deliberately.** `DueAt`, `now` and the edges are
175//     int64 in whatever unit the caller keeps consistently — block heights or
176//     unix seconds. The rule and its rationale are adopted from
177//     [p/moul/x/games/accrual]: prefer a timestamp, because a height-indexed
178//     band drifts in wall-clock terms every time the chain's block time
179//     moves. "30 days" is the caller's arithmetic, not a constant here. See
180//     DISCOVERY.md § 4, which attributes the decision.
181//   - **No opinion on where the rates come from.** Picking loss rates is the
182//     whole of credit policy and none of it is in this package. The rates are
183//     an input, validated for shape and ordering and never for plausibility.
184//   - **No write-off, no recovery, no roll-forward.** A provision is a point
185//     estimate from one snapshot. Moving a receivable out of the pool, and
186//     reversing it when a payment arrives late, are a realm's job because
187//     both are state changes and nothing here holds state.
188//
189// [p/spendbook]: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/spendbook
190// [p/depreciation]: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/depreciation
191// [r/invoices]: /r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/invoices
192// [p/twap]: /p/moul/x/twap/v0
193// [p/moul/x/games/accrual]: /p/moul/x/games/accrual/v0
194package aging
195
196import "errors"
197
198// RateScale is the denominator every loss rate is expressed against, so a
199// rate is in basis points: 0 is 0%, 2500 is 25%, 10000 is 100%.
200//
201// It is a constant rather than a per-schedule field on purpose. A schedule
202// carrying its own denominator lets two schedules in one realm be priced on
203// different scales, and the first symptom of that is a provision wrong by a
204// factor of a hundred with every individual number looking reasonable.
205const RateScale int64 = 10000
206
207// MaxBuckets bounds the number of age bands, and so bounds the edge scan in
208// [Schedule.BucketOf] and the loops in [Schedule.ReportAt] and
209// [Schedule.Provision]. 16 is past the point where a band is meaningful: a
210// conventional aging report uses four or five.
211//
212// The minimum is 2, and that is a refusal rather than an omission. A
213// one-bucket schedule has no edges, applies one rate to the whole balance,
214// and is a flat-rate provision rather than an aging schedule — legitimate
215// arithmetic, better said as `floor(total * rate / RateScale)` at the call
216// site, where a reader can see that no aging happened.
217const MaxBuckets = 16
218
219// MaxObligations bounds how many obligations one [Schedule.ReportAt] call
220// will accept, following [p/lots]' own 1024 and for the same reason: the
221// caller's slice length is the only thing that bounds the loop, and an
222// unbounded loop over caller-supplied data is a gas hazard a realm cannot
223// size in advance.
224//
225// What the bound costs: a realm with more than 1024 open obligations must
226// report in chunks and add the provisions, and that sum is **not** the
227// provision of the whole. Each chunk floors its own buckets, so chunking
228// loses up to one unit per bucket per chunk — the same effect the package doc
229// bounds for the clock, arriving by a different route. A realm that needs the
230// exact figure over a larger pool must accumulate the bucket totals itself
231// and call [Schedule.Provision] once on the combined report, which is
232// supported: `Provision` takes a [Report] and does not care where it came
233// from.
234//
235// [p/lots]: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/lots
236const MaxObligations = 1024
237
238var (
239	// ErrBadBucketCount is returned when a schedule would have fewer than 2
240	// or more than MaxBuckets buckets. The bucket count is len(edges)+1, so
241	// this is a statement about the edge slice: it must hold between 1 and
242	// MaxBuckets-1 entries. A nil or empty edge slice lands here, which is
243	// what makes the zero Schedule invalid.
244	ErrBadBucketCount = errors.New("aging: bucket count must be in [2, MaxBuckets]")
245
246	// ErrBadEdges is returned when the edges are not positive and strictly
247	// increasing. Equal adjacent edges would define an empty bucket, which
248	// is not an error in arithmetic but is always an authoring mistake: it
249	// silently reduces the schedule's resolution while every bucket total
250	// still reports.
251	//
252	// Positive and not merely non-negative, for the same reason one step
253	// earlier: a first edge of 0 makes bucket 0 the empty range [0,0). The
254	// rationale here is the whole of the argument for refusing it, which is
255	// why the check now reaches the first edge too; see check.
256	ErrBadEdges = errors.New("aging: edges must be positive and strictly increasing")
257
258	// ErrBadRateCount is returned when len(rates) != len(edges)+1. The
259	// off-by-one here is the most likely way to build a wrong schedule that
260	// still looks right, so it is named apart from ErrBadRate: a reader
261	// seeing this error knows to count, not to look at values.
262	ErrBadRateCount = errors.New("aging: need exactly one rate per bucket, which is len(edges)+1")
263
264	// ErrBadRate is returned when a rate is negative or exceeds RateScale.
265	// A rate above 100% would provision more than is owed; a negative rate
266	// would provision a gain.
267	ErrBadRate = errors.New("aging: each rate must be in [0, RateScale]")
268
269	// ErrRatesDecrease is returned when the rates fall anywhere across the
270	// buckets. See the package doc: it is both the domain claim and the
271	// precondition for the only monotonicity this package offers.
272	ErrRatesDecrease = errors.New("aging: rates must not decrease as buckets age")
273
274	// ErrNotYetDue is returned for an obligation whose DueAt has not
275	// arrived, and by BucketOf for a negative age — which is the same
276	// condition expressed in age terms. Refused rather than bucketed; see
277	// the package doc.
278	ErrNotYetDue = errors.New("aging: obligation is not yet due and has no age")
279
280	// ErrBadAmount is returned for an obligation amount that is not
281	// positive. Zero is refused as well as negative: a zero obligation
282	// consumes one of the MaxObligations slots and changes nothing, so it
283	// is a caller bug worth surfacing rather than a free no-op.
284	ErrBadAmount = errors.New("aging: obligation amount must be positive")
285
286	// ErrTooManyObligations is returned when more than MaxObligations are
287	// passed in one call. See MaxObligations on chunking, and on why the
288	// chunked sum is not the same number.
289	ErrTooManyObligations = errors.New("aging: more than MaxObligations in one report")
290
291	// ErrBadReport is returned by Provision when a report's bucket count
292	// does not match the schedule's. It is the only structural check
293	// available: a Report carries no identity, so two schedules with the
294	// same bucket count accept each other's reports. A Report must
295	// therefore not outlive the Schedule that produced it; prefer
296	// ProvisionFor, which makes that impossible.
297	ErrBadReport = errors.New("aging: report bucket count does not match the schedule")
298
299	// ErrCrossFoot is returned by Provision when a report's bucket totals
300	// do not sum to its stated Total. Reports built by ReportAt always
301	// cross-foot by construction, so this fires only for a caller-built or
302	// mutated Report — which is exactly why it is an error here and not
303	// merely an assertion in the test suite.
304	ErrCrossFoot = errors.New("aging: report buckets do not sum to its total")
305
306	// ErrBadBucketIndex is returned by the per-bucket accessors for an
307	// index outside [0, NumBuckets).
308	ErrBadBucketIndex = errors.New("aging: bucket index out of range")
309
310	// ErrOverflow is returned when the arithmetic would wrap: an age that
311	// does not fit in an int64, a bucket total or report total that exceeds
312	// what can be priced at RateScale, or a sum of amounts that does not
313	// fit. Every other overflow is excluded by the construction-time
314	// checks; see check.
315	//
316	// The pricing bound is enforced in both places it is reachable, which is
317	// deliberate and not redundant: ReportAt bounds the running total so
318	// that a report it returns can always be priced, and ProvisionByBucket
319	// bounds each bucket because it also accepts caller-built reports it
320	// never produced. Dropping either one is a reachable defect, and each
321	// has its own sabotage case.
322	ErrOverflow = errors.New("aging: int64 overflow")
323)
324
325// maxInt64 is written as a literal rather than as `int64(1)<<63 - 1`, which
326// does not compile: the shift is evaluated in the already-typed int64 and
327// overflows before the subtraction brings it back. The same note is in
328// [p/depreciation]; it is repeated because it is the same shape as the
329// arithmetic this package exists to get right.
330const maxInt64 int64 = 9223372036854775807
331
332// Obligation is one thing owed, as the pair this package needs: how much is
333// still unpaid and when it fell due.
334//
335// Amount is the unpaid remainder, not a face amount — on [r/invoices] that is
336// `amount - paid`. DueAt is in the caller's time unit, consistently with the
337// `now` passed to [Schedule.ReportAt] and with the schedule's edges.
338//
339// It is a value and carries no identity. This package cannot tell two
340// obligations apart and does not try: the same obligation passed twice is
341// counted twice, and deduplication is the caller's, because only the caller
342// knows what makes one of its receivables the same as another.
343type Obligation struct {
344	Amount int64
345	DueAt  int64
346}
347
348// Report is one aging snapshot: the unpaid total in each bucket, youngest
349// first, and their sum.
350//
351// Its fields are exported so a caller can render the line items, and so a
352// realm can accumulate buckets across several [Schedule.ReportAt] calls and
353// price them in one go — see [MaxObligations]. The cost of exporting them is
354// that a Report can be built or mutated into something inconsistent, which is
355// why [Schedule.Provision] re-derives nothing and instead validates: bucket
356// count against the schedule, each bucket non-negative, and the cross-foot.
357//
358// A Report carries no reference to the Schedule that made it. Two schedules
359// with the same bucket count will accept each other's reports and silently
360// price them on the wrong rates, so a Report must not outlive its Schedule.
361// [Schedule.ProvisionFor] exists to remove the opportunity.
362type Report struct {
363	// Buckets holds the unpaid total per bucket, index 0 being the
364	// youngest. Its length is the schedule's NumBuckets.
365	Buckets []int64
366
367	// Total is the sum of Buckets. It is stored rather than recomputed so
368	// that Provision has something to cross-foot against.
369	Total int64
370}
371
372// Schedule is an aging schedule: the age boundaries and the loss rate for
373// each resulting band.
374//
375// Its fields are unexported and the accessors copy, which is a deliberate
376// departure from [p/depreciation]'s all-scalar exported Schedule. A schedule
377// holding slices is not safely copyable: a caller that kept the slice it
378// passed to [New] could mutate the edges after validation and every
379// subsequent call would price against a schedule that never passed `check`.
380// Construct with [New].
381type Schedule struct {
382	// edges holds the bucket boundaries, strictly increasing and
383	// non-negative. Bucket i is the half-open age range
384	// [edges[i-1], edges[i]), with bucket 0 starting at 0 and the last
385	// bucket unbounded above.
386	edges []int64
387
388	// rates holds one loss rate per bucket in units of RateScale,
389	// non-decreasing. len(rates) == len(edges)+1.
390	rates []int64
391}
392
393// New validates and returns a schedule. The edges are the age boundaries,
394// positive and strictly increasing; the rates are one per bucket, so one
395// more than there are edges, each in [0, RateScale] and non-decreasing.
396//
397// Positive, so the smallest legal first edge is 1 and the smallest legal
398// schedule is `New([]int64{1}, []int64{0, RateScale})` — which is exactly the
399// degenerate strictly-overdue schedule the package doc describes. A first edge
400// of 0 is [ErrBadEdges]: it would define an empty bucket 0.
401//
402//	New([]int64{30, 60, 90}, []int64{0, 1000, 2500, 5000})
403//
404// reads as: nothing provisioned under 30 past due, 10% from 30 to 59, 25%
405// from 60 to 89, 50% from 90 on.
406//
407// Both slices are copied, so the caller may reuse or mutate its own.
408func New(edges, rates []int64) (Schedule, error) {
409	e := make([]int64, len(edges))
410	copy(e, edges)
411	r := make([]int64, len(rates))
412	copy(r, rates)
413	s := Schedule{edges: e, rates: r}
414	if err := s.check(); err != nil {
415		return Schedule{}, err
416	}
417	return s, nil
418}
419
420// check validates every field and establishes the overflow precondition that
421// lets [Schedule.Provision] multiply without a guard per term.
422//
423// Every exported method calls it rather than trusting its receiver, so the
424// zero Schedule fails everywhere instead of pricing against nil slices. The
425// cost is at most 31 comparisons per call; see the package doc on bounds.
426func (s Schedule) check() error {
427	if len(s.edges) < 1 || len(s.edges) > MaxBuckets-1 {
428		return ErrBadBucketCount
429	}
430	if len(s.rates) != len(s.edges)+1 {
431		return ErrBadRateCount
432	}
433	// `<= 0` and not `< 0`. Bucket 0 is the half-open range [0, edges[0]), so
434	// an edges[0] of 0 makes it [0,0) — occupiable by nothing, for every input,
435	// forever — while NumBuckets still reports it and every bucket total still
436	// prints. That is precisely the condition ErrBadEdges refuses between
437	// adjacent edges, and the first edge was the one place the rule did not
438	// reach. Shipped as `< 0` and corrected before deployment; the measurement
439	// and the audit finding are in AUDIT.md Y2.
440	if s.edges[0] <= 0 {
441		return ErrBadEdges
442	}
443	for i := 1; i < len(s.edges); i++ {
444		if s.edges[i] <= s.edges[i-1] {
445			return ErrBadEdges
446		}
447	}
448	for i, rate := range s.rates {
449		if rate < 0 || rate > RateScale {
450			return ErrBadRate
451		}
452		if i > 0 && rate < s.rates[i-1] {
453			return ErrRatesDecrease
454		}
455	}
456	return nil
457}
458
459// Valid reports whether the schedule is well-formed. A zero Schedule is not.
460func (s Schedule) Valid() bool { return s.check() == nil }
461
462// NumBuckets is the number of age bands, which is one more than the number of
463// edges. It returns 0 for an invalid schedule, including the zero value, so a
464// caller that skipped [Schedule.Valid] gets a number that reads as wrong
465// rather than one that reads as a one-bucket schedule.
466func (s Schedule) NumBuckets() int {
467	if s.check() != nil {
468		return 0
469	}
470	return len(s.edges) + 1
471}
472
473// Edges returns a copy of the bucket boundaries.
474func (s Schedule) Edges() []int64 {
475	out := make([]int64, len(s.edges))
476	copy(out, s.edges)
477	return out
478}
479
480// Rates returns a copy of the per-bucket loss rates, in units of [RateScale].
481func (s Schedule) Rates() []int64 {
482	out := make([]int64, len(s.rates))
483	copy(out, s.rates)
484	return out
485}
486
487// BucketRange returns the half-open age range of bucket i as
488// [lo, hi), together with whether it is unbounded above — which is true for
489// the last bucket and only the last bucket, where hi is meaningless.
490//
491// It exists so that a caller rendering line items does not have to
492// reconstruct the half-open convention from [Schedule.Edges]. That
493// reconstruction is where an off-by-one lives, and this package has already
494// shipped one of those in its own discovery record; see the package doc.
495func (s Schedule) BucketRange(i int) (lo, hi int64, unbounded bool, err error) {
496	if err := s.check(); err != nil {
497		return 0, 0, false, err
498	}
499	if i < 0 || i > len(s.edges) {
500		return 0, 0, false, ErrBadBucketIndex
501	}
502	if i > 0 {
503		lo = s.edges[i-1]
504	}
505	if i == len(s.edges) {
506		return lo, 0, true, nil
507	}
508	return lo, s.edges[i], false, nil
509}
510
511// Age returns how long an obligation has been due: now - dueAt.
512//
513// Zero is a legitimate age and means "due exactly now, not yet late". A
514// `dueAt` in the future is [ErrNotYetDue] rather than a negative age; see the
515// package doc on why it is refused rather than bucketed.
516//
517// The span is measured in uint64 and the reason is the whole correctness
518// argument, adopted verbatim from [p/depreciation]'s `Periods`. `now >= dueAt`
519// is enforced first, so the true difference is non-negative; its largest
520// value is maxInt64 - minInt64 = 2^64 - 1, which does not fit in an int64 and
521// fits exactly in a uint64. int64 subtraction wraps two's-complement, so the
522// bit pattern of now - dueAt is already the low 64 bits of the true
523// difference, and reading it as a uint64 recovers that difference with no
524// loss and no reachable wrap. [ErrOverflow] therefore means exactly one thing
525// here — the true age exceeds maxInt64 — and the subtraction needs no guard.
526func Age(dueAt, now int64) (int64, error) {
527	if now < dueAt {
528		return 0, ErrNotYetDue
529	}
530	span := uint64(now) - uint64(dueAt)
531	if span > uint64(maxInt64) {
532		return 0, ErrOverflow
533	}
534	return int64(span), nil
535}
536
537// BucketOf returns the index of the bucket an age falls in: 0 for the
538// youngest, NumBuckets-1 for the unbounded oldest.
539//
540// The partition is half-open, [lo, hi), which is what makes it exhaustive and
541// disjoint over every non-negative age with no boundary shared by two
542// buckets. An age equal to an edge belongs to the **older** bucket, so edge
543// 30 means "30 and over is in the next band".
544//
545// A negative age is [ErrNotYetDue], the same error [Age] returns for the same
546// condition.
547func (s Schedule) BucketOf(age int64) (int, error) {
548	if err := s.check(); err != nil {
549		return 0, err
550	}
551	if age < 0 {
552		return 0, ErrNotYetDue
553	}
554	for i, edge := range s.edges {
555		if age < edge {
556			return i, nil
557		}
558	}
559	return len(s.edges), nil
560}
561
562// ReportAt partitions the obligations by age at time `now` and returns the
563// per-bucket unpaid totals and their sum.
564//
565// Every obligation must be due — `DueAt <= now` — and must carry a positive
566// amount; the first that is not fails the whole call, because a partial report
567// that silently dropped a receivable is worse than no report. Which
568// obligation failed is not in the error: a sentinel carries no index, and
569// wrapping was not worth a dependency here. A caller that needs to locate the
570// bad entry has [Age] and [Obligation.Amount] and can find it in one pass.
571//
572// An empty slice is legitimate and returns a zeroed report, not an error: a
573// realm with nothing outstanding has a provision of zero and should not have
574// to special-case it.
575//
576// Cost: at most MaxObligations × (MaxBuckets-1) comparisons; see the package
577// doc.
578func (s Schedule) ReportAt(obs []Obligation, now int64) (Report, error) {
579	if err := s.check(); err != nil {
580		return Report{}, err
581	}
582	if len(obs) > MaxObligations {
583		return Report{}, ErrTooManyObligations
584	}
585	buckets := make([]int64, len(s.edges)+1)
586	var total int64
587	for _, ob := range obs {
588		if ob.Amount <= 0 {
589			return Report{}, ErrBadAmount
590		}
591		age, err := Age(ob.DueAt, now)
592		if err != nil {
593			return Report{}, err
594		}
595		i, err := s.BucketOf(age)
596		if err != nil {
597			return Report{}, err
598		}
599		// Guard the running total first: it dominates every bucket, so a
600		// total that fits implies every bucket fits.
601		if ob.Amount > maxInt64-total {
602			return Report{}, ErrOverflow
603		}
604		total += ob.Amount
605		// And establish the PRICING precondition here, not one call later.
606		// The guard above only says the total does not wrap; pricing needs
607		// the stronger `b <= maxInt64/RateScale` that ProvisionByBucket
608		// enforces per bucket, and the total dominates every bucket in the
609		// same direction. Without this line ReportAt returned, with a nil
610		// error, reports that Provision then refused with ErrOverflow — the
611		// two thresholds differed by RateScale, a factor of 10,000. Measured
612		// before the fix: one obligation of maxInt64 reported cleanly and
613		// priced as ErrOverflow. See AUDIT.md Y1.
614		//
615		// Both guards are kept and both are reachable: this one fires on an
616		// accumulation that cannot be priced, the one above on a single
617		// amount within RateScale of maxInt64, which this one cannot see
618		// because `maxInt64-total` is the quantity that wraps there.
619		if total > maxInt64/RateScale {
620			return Report{}, ErrOverflow
621		}
622		buckets[i] += ob.Amount
623	}
624	return Report{Buckets: buckets, Total: total}, nil
625}
626
627// ProvisionByBucket returns the expected credit loss per bucket: the
628// line-item column, youngest first.
629//
630// Each entry is `floor(bucketᵢ × rateᵢ / RateScale)`, the floor taken per
631// bucket so that this column sums to exactly what [Schedule.Provision]
632// reports. See the package doc for what that choice costs, with the
633// counterexample in full.
634//
635// The report is validated rather than trusted: its bucket count must match
636// the schedule, every bucket must be non-negative, and the buckets must sum
637// to the report's stated Total ([ErrCrossFoot]). A report from
638// [Schedule.ReportAt] satisfies all three by construction.
639//
640// A fourth property is needed and is NOT one of the three: every bucket must
641// be at most maxInt64/RateScale, or the multiplication below wraps. Say it
642// separately because the three-item sentence above was true, complete about
643// the three, and still read as "a ReportAt report always prices" — which it
644// did not guarantee. ReportAt now bounds its running total by the same
645// quantity, so the implicature is true; it is true because of a line in
646// ReportAt, not because of anything here. See AUDIT.md Y1.
647func (s Schedule) ProvisionByBucket(r Report) ([]int64, error) {
648	if err := s.check(); err != nil {
649		return nil, err
650	}
651	if len(r.Buckets) != len(s.edges)+1 {
652		return nil, ErrBadReport
653	}
654	out := make([]int64, len(r.Buckets))
655	var sum int64
656	for i, b := range r.Buckets {
657		if b < 0 {
658			return nil, ErrBadAmount
659		}
660		if b > maxInt64-sum {
661			return nil, ErrOverflow
662		}
663		sum += b
664		// b*rate is the only product. The guard is per bucket rather than
665		// on the total because a caller-built report's Total is not yet
666		// trusted at this point — the cross-foot below is what establishes
667		// it, and ordering the check the other way would let a report with
668		// a wrong Total choose its own overflow precondition.
669		if b > maxInt64/RateScale {
670			return nil, ErrOverflow
671		}
672		out[i] = (b * s.rates[i]) / RateScale
673	}
674	if sum != r.Total {
675		return nil, ErrCrossFoot
676	}
677	return out, nil
678}
679
680// Provision returns the expected credit loss for a report: one number for a
681// ledger.
682//
683// It is defined as the sum of [Schedule.ProvisionByBucket], so the single
684// figure and the line items can never disagree — the same reason
685// [p/depreciation] defines `Charge` as a difference of `CarryingAt`. The sum
686// cannot overflow: each term is at most its bucket, because every rate is at
687// most RateScale, and the buckets were established to sum without overflow.
688func (s Schedule) Provision(r Report) (int64, error) {
689	parts, err := s.ProvisionByBucket(r)
690	if err != nil {
691		return 0, err
692	}
693	var sum int64
694	for _, p := range parts {
695		sum += p
696	}
697	return sum, nil
698}
699
700// ProvisionFor is [Schedule.ReportAt] followed by [Schedule.Provision], for a
701// caller that wants the number and not the line items.
702//
703// Prefer it over holding a [Report]: a Report carries no reference to the
704// schedule that produced it, so a stored one can be priced against a
705// different schedule of the same bucket count with no error. Doing both steps
706// in one call removes the opportunity.
707func (s Schedule) ProvisionFor(obs []Obligation, now int64) (int64, error) {
708	rep, err := s.ReportAt(obs, now)
709	if err != nil {
710		return 0, err
711	}
712	return s.Provision(rep)
713}