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}