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 source pure

Package aging partitions a set of unpaid obligations by how long each has been past due, and prices the resulting sch...

Overview

Package aging partitions a set of unpaid obligations by how long each has been past due, and prices the resulting schedule into a single expected loss.

A realm holds the obligations — invoices, loans, dues — and asks this package two questions: how is what I am owed distributed across age bands, and what should I expect not to collect. Nothing here reads the chain, holds state, or knows what an invoice is, so a realm can test a year of its own receivables without a node.

Example
1s, _ := New([]int64{30, 60, 90}, []int64{0, 1000, 2500, 5000})
2rep, err := s.ReportAt(obs, now)   // four bucket totals and their sum
3loss, err := s.Provision(rep)      // the expected credit loss

The two steps are separable on purpose: the report is the line items a reader sees, and the provision is the one number that goes in a ledger. Schedule.ProvisionFor does both in one call for a caller that only wants the number.

The output is a provision, and never an allowance

The textbook name for this number is "allowance for doubtful accounts", and that word is not available here. `allowance` already means a *spending* allowance across this portfolio — including p/spendbook's `Allowance` type — and it means an approve/transferFrom quota across the wider ecosystem's GRC20 realms. A type named `Allowance` in a package imported beside either of those would read as the wrong thing at every call site, so the loss number is `Provision` throughout. See DISCOVERY.md § 4.

Why the floor is taken per bucket, and exactly what that costs

The provision is the sum of per-bucket floors:

Example
1Provision = Σ floor(bucketᵢ × rateᵢ / RateScale)

not one floor taken at the end of an exact rational total. Those are different numbers, and which one a package shipped is invisible in any test that happens to use round rates.

Per-bucket is chosen because Schedule.ProvisionByBucket is the line-item column a reader sees next to the bucket totals, and line items that do not add up to the stated total are a bookkeeping defect regardless of which total is "more accurate". Schedule.Provision is *defined* as the sum of that column, so the two cannot disagree — the same discipline p/depreciation uses to define `Charge` in terms of `CarryingAt`.

The cost of that choice is a real and measurable weakening of the obvious invariant, and it is stated here because the discovery record for this package asserted the strong form and the strong form is false:

Example
1FALSE: for a fixed unpaid set, Provision is non-decreasing in now.

The counterexample is small enough to state in full. Take edges `[30]` and rates `[5000, 5000]` — one boundary, the same 50% either side — and two obligations of amount 1. While both sit in bucket 0 the buckets are `[2, 0]` and the provision is `floor(2×5000/10000) = 1`. Let one age past 30: the buckets become `[1, 1]`, each floors to `0`, and the provision is `0`. **It fell as the receivables got older**, with no payment and no change of rate. One floor at the end would have returned 1 in both states.

What is true, and what [TestProvisionNearlyMonotone] asserts:

  • The exact rational provision Σ bucketᵢ×rateᵢ/RateScale **is** non-decreasing in `now`, because an obligation only ever moves to a higher-indexed bucket and New requires the rates to be non-decreasing. That is the economic claim, and it survives.
  • The integer provision is non-decreasing **to within fewer than NumBuckets units**: each bucket loses strictly less than 1 to its floor, and only a non-empty bucket loses anything, so `Provision(later) > Provision(earlier) - min(NumBuckets, len(obs))`. That bound is **attained**, not merely sound, and the figure is left to [TestTheMonotonicityBoundIsAttained] rather than restated here: at a flat rate of `RateScale-1`, `n` obligations of amount 1 price to `n-1` when they share a bucket and to 0 when they are one per bucket, so the drop is exactly `min(NumBuckets, len(obs)) - 1` at every bucket count this package allows. No slack is available to an implementation that wanted some.

The random sweep in [TestProvisionNearlyMonotone] reports a far smaller largest drop than that, and the difference is a property of the sweep rather than of the package: with randomly drawn rates the shape that loses the most to the floors essentially never comes up. Both run, and the constructed one is the one the bound is quoted from.

So the provision is safe to re-run and safe to re-run often — it cannot wander upward, and it cannot fall by more than a rounding unit per bucket. It is not a monotone function, and a realm that gates on `provision >= lastProvision` will eventually be wrong by one. [TestFloorIsPerBucketNotAtTheEnd] pins the counterexample itself, so a later "fix" that quietly moves to a single floor at the end fails the suite rather than silently changing every published number.

Why the rates must be non-decreasing

New refuses a rate schedule that falls anywhere. Two reasons, and the second is the load-bearing one:

  • It is the domain claim. An older receivable is not more likely to be collected than a younger one. A schedule that says otherwise is almost always a transposed array, which is a real and silent authoring error: `{5000, 2500, 1000, 0}` is a plausible-looking argument and prices a portfolio inside-out.
  • Without it the *exact rational* monotonicity above fails too, and then nothing is left. An obligation crossing an edge into a cheaper bucket reduces the exact provision, so the only invariant this package offers would be false in its strong form as well as its integer form.

Why a not-yet-due obligation is refused

An obligation with `DueAt > now` has no age, and the choices are to refuse it or to put it in the youngest bucket. Bucketing it would be silent and would inflate every provision by whatever the youngest rate is applied to money that nobody is late paying. So it is ErrNotYetDue, following `r/books`' `TestKindOfReportsInvalidRatherThanBucketing` — refused, not silently bucketed.

The "current" column of a conventional aging report is therefore the caller's, and the caller already has it: on r/invoices it is `OutstandingFrom - OverdueFrom`. This package describes the past-due tail and declines to guess about the rest.

The degenerate two-bucket case, and the off-by-one in it

A two-bucket schedule with edge `1` and rates `{0, RateScale}` reproduces r/invoices' `OverdueFrom` exactly, over the obligations whose `DueAt` has arrived: everything strictly past due is priced at 100% and everything else at 0%.

The edge is **1 and not 0**, which is worth the sentence because DISCOVERY.md § 1 says 0 and is wrong. `isOverdue` is `now > dueHeight`, strictly — an invoice at exactly its due height is due and not yet late, so that invoice has age 0 and must land in the *unpriced* bucket. Edge 0 makes bucket 0 empty and prices it at 100%, overstating the loss by every invoice due precisely today. An off-by-one on a half-open boundary is the defect class this package is most exposed to, and the discovery record shipped one in the very claim that identified the consumer.

Edge 0 is now refused outright — ErrBadEdges, see check — so the overstatement described above is no longer expressible rather than merely documented. It was expressible, and accepted, until the pre-deployment audit found it: AUDIT.md Y2. A paragraph naming a defect class while the validator admitted an instance of it is the shape worth remembering here.

Note also what an `Amount` is in that correspondence: `OverdueFrom` sums `amount - paid`, the unpaid remainder. [Obligation.Amount] is that remainder and not a face amount. A caller that passes face amounts prices money it has already collected.

What bounds the work

Bucket lookup is a linear scan over the edges, which is cheaper than a binary search at these sizes and is the shape an auditor can check by reading. MaxBuckets is 16, so there are at most 15 edges, and MaxObligations is 1024: one Schedule.ReportAt therefore performs at most 15,360 comparisons plus 1024 additions, and Schedule.Provision performs at most 16 multiplications and divisions. Every exported method re-runs `check`, which costs at most 31 further comparisons.

Those are the figures, not an assertion that they are affordable. A realm that ages several portfolios in one transaction, or does it inside a `Render`, must size them against the gas it has. This package bounds the work; it does not price it.

What this package does not claim

  • **No novelty for partitioning time into buckets.** p/twap on gnoland-1 quantises an observation series into fixed-width windows and was read in full before this was written; what was not found in the searched sources is the domain model — obligations, a past-due age, a per-band loss rate, a provision. See DISCOVERY.md § 1 and § 3.
  • **Nothing about money.** A provision is an estimate in a report. This package never sees a coin, and a realm using it must not infer custody or a transfer from it.
  • **No unit of time, deliberately.** `DueAt`, `now` and the edges are int64 in whatever unit the caller keeps consistently — block heights or unix seconds. The rule and its rationale are adopted from p/moul/x/games/accrual: prefer a timestamp, because a height-indexed band drifts in wall-clock terms every time the chain's block time moves. "30 days" is the caller's arithmetic, not a constant here. See DISCOVERY.md § 4, which attributes the decision.
  • **No opinion on where the rates come from.** Picking loss rates is the whole of credit policy and none of it is in this package. The rates are an input, validated for shape and ordering and never for plausibility.
  • **No write-off, no recovery, no roll-forward.** A provision is a point estimate from one snapshot. Moving a receivable out of the pool, and reversing it when a payment arrives late, are a realm's job because both are state changes and nothing here holds state.

p/spendbook: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/spendbook p/depreciation: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/depreciation r/invoices: /r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/invoices p/twap: /p/moul/x/twap/v0 p/moul/x/games/accrual: /p/moul/x/games/accrual/v0

Constants 3

const MaxBuckets

1const MaxBuckets = 16
source

MaxBuckets bounds the number of age bands, and so bounds the edge scan in Schedule.BucketOf and the loops in Schedule.ReportAt and Schedule.Provision. 16 is past the point where a band is meaningful: a conventional aging report uses four or five.

The minimum is 2, and that is a refusal rather than an omission. A one-bucket schedule has no edges, applies one rate to the whole balance, and is a flat-rate provision rather than an aging schedule — legitimate arithmetic, better said as `floor(total * rate / RateScale)` at the call site, where a reader can see that no aging happened.

const MaxObligations

1const MaxObligations = 1024
source

MaxObligations bounds how many obligations one Schedule.ReportAt call will accept, following p/lots' own 1024 and for the same reason: the caller's slice length is the only thing that bounds the loop, and an unbounded loop over caller-supplied data is a gas hazard a realm cannot size in advance.

What the bound costs: a realm with more than 1024 open obligations must report in chunks and add the provisions, and that sum is **not** the provision of the whole. Each chunk floors its own buckets, so chunking loses up to one unit per bucket per chunk — the same effect the package doc bounds for the clock, arriving by a different route. A realm that needs the exact figure over a larger pool must accumulate the bucket totals itself and call Schedule.Provision once on the combined report, which is supported: `Provision` takes a Report and does not care where it came from.

p/lots: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/lots

const RateScale

1const RateScale int64 = 10000
source

RateScale is the denominator every loss rate is expressed against, so a rate is in basis points: 0 is 0%, 2500 is 25%, 10000 is 100%.

It is a constant rather than a per-schedule field on purpose. A schedule carrying its own denominator lets two schedules in one realm be priced on different scales, and the first symptom of that is a provision wrong by a factor of a hundred with every individual number looking reasonable.

Variables 1

var ErrBadBucketCount, ErrBadEdges, ErrBadRateCount, ErrBadRate, ErrRatesDecrease, ErrNotYetDue, ErrBadAmount, ErrTooManyObligations, ErrBadReport, ErrCrossFoot, ErrBadBucketIndex, ErrOverflow

 1var (
 2	// ErrBadBucketCount is returned when a schedule would have fewer than 2
 3	// or more than MaxBuckets buckets. The bucket count is len(edges)+1, so
 4	// this is a statement about the edge slice: it must hold between 1 and
 5	// MaxBuckets-1 entries. A nil or empty edge slice lands here, which is
 6	// what makes the zero Schedule invalid.
 7	ErrBadBucketCount = errors.New("aging: bucket count must be in [2, MaxBuckets]")
 8
 9	// ErrBadEdges is returned when the edges are not positive and strictly
10	// increasing. Equal adjacent edges would define an empty bucket, which
11	// is not an error in arithmetic but is always an authoring mistake: it
12	// silently reduces the schedule's resolution while every bucket total
13	// still reports.
14	//
15	// Positive and not merely non-negative, for the same reason one step
16	// earlier: a first edge of 0 makes bucket 0 the empty range [0,0). The
17	// rationale here is the whole of the argument for refusing it, which is
18	// why the check now reaches the first edge too; see check.
19	ErrBadEdges = errors.New("aging: edges must be positive and strictly increasing")
20
21	// ErrBadRateCount is returned when len(rates) != len(edges)+1. The
22	// off-by-one here is the most likely way to build a wrong schedule that
23	// still looks right, so it is named apart from ErrBadRate: a reader
24	// seeing this error knows to count, not to look at values.
25	ErrBadRateCount = errors.New("aging: need exactly one rate per bucket, which is len(edges)+1")
26
27	// ErrBadRate is returned when a rate is negative or exceeds RateScale.
28	// A rate above 100% would provision more than is owed; a negative rate
29	// would provision a gain.
30	ErrBadRate = errors.New("aging: each rate must be in [0, RateScale]")
31
32	// ErrRatesDecrease is returned when the rates fall anywhere across the
33	// buckets. See the package doc: it is both the domain claim and the
34	// precondition for the only monotonicity this package offers.
35	ErrRatesDecrease = errors.New("aging: rates must not decrease as buckets age")
36
37	// ErrNotYetDue is returned for an obligation whose DueAt has not
38	// arrived, and by BucketOf for a negative age — which is the same
39	// condition expressed in age terms. Refused rather than bucketed; see
40	// the package doc.
41	ErrNotYetDue = errors.New("aging: obligation is not yet due and has no age")
42
43	// ErrBadAmount is returned for an obligation amount that is not
44	// positive. Zero is refused as well as negative: a zero obligation
45	// consumes one of the MaxObligations slots and changes nothing, so it
46	// is a caller bug worth surfacing rather than a free no-op.
47	ErrBadAmount = errors.New("aging: obligation amount must be positive")
48
49	// ErrTooManyObligations is returned when more than MaxObligations are
50	// passed in one call. See MaxObligations on chunking, and on why the
51	// chunked sum is not the same number.
52	ErrTooManyObligations = errors.New("aging: more than MaxObligations in one report")
53
54	// ErrBadReport is returned by Provision when a report's bucket count
55	// does not match the schedule's. It is the only structural check
56	// available: a Report carries no identity, so two schedules with the
57	// same bucket count accept each other's reports. A Report must
58	// therefore not outlive the Schedule that produced it; prefer
59	// ProvisionFor, which makes that impossible.
60	ErrBadReport = errors.New("aging: report bucket count does not match the schedule")
61
62	// ErrCrossFoot is returned by Provision when a report's bucket totals
63	// do not sum to its stated Total. Reports built by ReportAt always
64	// cross-foot by construction, so this fires only for a caller-built or
65	// mutated Report — which is exactly why it is an error here and not
66	// merely an assertion in the test suite.
67	ErrCrossFoot = errors.New("aging: report buckets do not sum to its total")
68
69	// ErrBadBucketIndex is returned by the per-bucket accessors for an
70	// index outside [0, NumBuckets).
71	ErrBadBucketIndex = errors.New("aging: bucket index out of range")
72
73	// ErrOverflow is returned when the arithmetic would wrap: an age that
74	// does not fit in an int64, a bucket total or report total that exceeds
75	// what can be priced at RateScale, or a sum of amounts that does not
76	// fit. Every other overflow is excluded by the construction-time
77	// checks; see check.
78	//
79	// The pricing bound is enforced in both places it is reachable, which is
80	// deliberate and not redundant: ReportAt bounds the running total so
81	// that a report it returns can always be priced, and ProvisionByBucket
82	// bounds each bucket because it also accepts caller-built reports it
83	// never produced. Dropping either one is a reachable defect, and each
84	// has its own sabotage case.
85	ErrOverflow = errors.New("aging: int64 overflow")
86)
source

Functions 2

func Age

1func Age(dueAt, now int64) (int64, error)
source

Age returns how long an obligation has been due: now - dueAt.

Zero is a legitimate age and means "due exactly now, not yet late". A `dueAt` in the future is ErrNotYetDue rather than a negative age; see the package doc on why it is refused rather than bucketed.

The span is measured in uint64 and the reason is the whole correctness argument, adopted verbatim from p/depreciation's `Periods`. `now >= dueAt` is enforced first, so the true difference is non-negative; its largest value is maxInt64 - minInt64 = 2^64 - 1, which does not fit in an int64 and fits exactly in a uint64. int64 subtraction wraps two's-complement, so the bit pattern of now - dueAt is already the low 64 bits of the true difference, and reading it as a uint64 recovers that difference with no loss and no reachable wrap. ErrOverflow therefore means exactly one thing here — the true age exceeds maxInt64 — and the subtraction needs no guard.

func New

1func New(edges, rates []int64) (Schedule, error)
source

New validates and returns a schedule. The edges are the age boundaries, positive and strictly increasing; the rates are one per bucket, so one more than there are edges, each in [0, RateScale] and non-decreasing.

Positive, so the smallest legal first edge is 1 and the smallest legal schedule is `New([]int64{1}, []int64{0, RateScale})` — which is exactly the degenerate strictly-overdue schedule the package doc describes. A first edge of 0 is ErrBadEdges: it would define an empty bucket 0.

Example
1New([]int64{30, 60, 90}, []int64{0, 1000, 2500, 5000})

reads as: nothing provisioned under 30 past due, 10% from 30 to 59, 25% from 60 to 89, 50% from 90 on.

Both slices are copied, so the caller may reuse or mutate its own.

Types 3

type Obligation

struct
1type Obligation struct {
2	Amount int64
3	DueAt  int64
4}
source

Obligation is one thing owed, as the pair this package needs: how much is still unpaid and when it fell due.

Amount is the unpaid remainder, not a face amount — on r/invoices that is `amount - paid`. DueAt is in the caller's time unit, consistently with the `now` passed to Schedule.ReportAt and with the schedule's edges.

It is a value and carries no identity. This package cannot tell two obligations apart and does not try: the same obligation passed twice is counted twice, and deduplication is the caller's, because only the caller knows what makes one of its receivables the same as another.

type Report

struct
1type Report struct {
2	// Buckets holds the unpaid total per bucket, index 0 being the
3	// youngest. Its length is the schedule's NumBuckets.
4	Buckets []int64
5
6	// Total is the sum of Buckets. It is stored rather than recomputed so
7	// that Provision has something to cross-foot against.
8	Total int64
9}
source

Report is one aging snapshot: the unpaid total in each bucket, youngest first, and their sum.

Its fields are exported so a caller can render the line items, and so a realm can accumulate buckets across several Schedule.ReportAt calls and price them in one go — see MaxObligations. The cost of exporting them is that a Report can be built or mutated into something inconsistent, which is why Schedule.Provision re-derives nothing and instead validates: bucket count against the schedule, each bucket non-negative, and the cross-foot.

A Report carries no reference to the Schedule that made it. Two schedules with the same bucket count will accept each other's reports and silently price them on the wrong rates, so a Report must not outlive its Schedule. Schedule.ProvisionFor exists to remove the opportunity.

type Schedule

struct
 1type Schedule struct {
 2	// edges holds the bucket boundaries, strictly increasing and
 3	// non-negative. Bucket i is the half-open age range
 4	// [edges[i-1], edges[i]), with bucket 0 starting at 0 and the last
 5	// bucket unbounded above.
 6	edges []int64
 7
 8	// rates holds one loss rate per bucket in units of RateScale,
 9	// non-decreasing. len(rates) == len(edges)+1.
10	rates []int64
11}
source

Schedule is an aging schedule: the age boundaries and the loss rate for each resulting band.

Its fields are unexported and the accessors copy, which is a deliberate departure from p/depreciation's all-scalar exported Schedule. A schedule holding slices is not safely copyable: a caller that kept the slice it passed to New could mutate the edges after validation and every subsequent call would price against a schedule that never passed `check`. Construct with New.

Methods on Schedule

func BucketOf

method on Schedule
1func (s Schedule) BucketOf(age int64) (int, error)
source

BucketOf returns the index of the bucket an age falls in: 0 for the youngest, NumBuckets-1 for the unbounded oldest.

The partition is half-open, [lo, hi), which is what makes it exhaustive and disjoint over every non-negative age with no boundary shared by two buckets. An age equal to an edge belongs to the **older** bucket, so edge 30 means "30 and over is in the next band".

A negative age is ErrNotYetDue, the same error Age returns for the same condition.

func BucketRange

method on Schedule
1func (s Schedule) BucketRange(i int) (lo, hi int64, unbounded bool, err error)
source

BucketRange returns the half-open age range of bucket i as [lo, hi), together with whether it is unbounded above — which is true for the last bucket and only the last bucket, where hi is meaningless.

It exists so that a caller rendering line items does not have to reconstruct the half-open convention from Schedule.Edges. That reconstruction is where an off-by-one lives, and this package has already shipped one of those in its own discovery record; see the package doc.

func Edges

method on Schedule
1func (s Schedule) Edges() []int64
source

Edges returns a copy of the bucket boundaries.

func NumBuckets

method on Schedule
1func (s Schedule) NumBuckets() int
source

NumBuckets is the number of age bands, which is one more than the number of edges. It returns 0 for an invalid schedule, including the zero value, so a caller that skipped Schedule.Valid gets a number that reads as wrong rather than one that reads as a one-bucket schedule.

func Provision

method on Schedule
1func (s Schedule) Provision(r Report) (int64, error)
source

Provision returns the expected credit loss for a report: one number for a ledger.

It is defined as the sum of Schedule.ProvisionByBucket, so the single figure and the line items can never disagree — the same reason p/depreciation defines `Charge` as a difference of `CarryingAt`. The sum cannot overflow: each term is at most its bucket, because every rate is at most RateScale, and the buckets were established to sum without overflow.

func ProvisionByBucket

method on Schedule
1func (s Schedule) ProvisionByBucket(r Report) ([]int64, error)
source

ProvisionByBucket returns the expected credit loss per bucket: the line-item column, youngest first.

Each entry is `floor(bucketᵢ × rateᵢ / RateScale)`, the floor taken per bucket so that this column sums to exactly what Schedule.Provision reports. See the package doc for what that choice costs, with the counterexample in full.

The report is validated rather than trusted: its bucket count must match the schedule, every bucket must be non-negative, and the buckets must sum to the report's stated Total (ErrCrossFoot). A report from Schedule.ReportAt satisfies all three by construction.

A fourth property is needed and is NOT one of the three: every bucket must be at most maxInt64/RateScale, or the multiplication below wraps. Say it separately because the three-item sentence above was true, complete about the three, and still read as "a ReportAt report always prices" — which it did not guarantee. ReportAt now bounds its running total by the same quantity, so the implicature is true; it is true because of a line in ReportAt, not because of anything here. See AUDIT.md Y1.

func ProvisionFor

method on Schedule
1func (s Schedule) ProvisionFor(obs []Obligation, now int64) (int64, error)
source

ProvisionFor is Schedule.ReportAt followed by Schedule.Provision, for a caller that wants the number and not the line items.

Prefer it over holding a Report: a Report carries no reference to the schedule that produced it, so a stored one can be priced against a different schedule of the same bucket count with no error. Doing both steps in one call removes the opportunity.

func Rates

method on Schedule
1func (s Schedule) Rates() []int64
source

Rates returns a copy of the per-bucket loss rates, in units of RateScale.

func ReportAt

method on Schedule
1func (s Schedule) ReportAt(obs []Obligation, now int64) (Report, error)
source

ReportAt partitions the obligations by age at time `now` and returns the per-bucket unpaid totals and their sum.

Every obligation must be due — `DueAt <= now` — and must carry a positive amount; the first that is not fails the whole call, because a partial report that silently dropped a receivable is worse than no report. Which obligation failed is not in the error: a sentinel carries no index, and wrapping was not worth a dependency here. A caller that needs to locate the bad entry has Age and [Obligation.Amount] and can find it in one pass.

An empty slice is legitimate and returns a zeroed report, not an error: a realm with nothing outstanding has a provision of zero and should not have to special-case it.

Cost: at most MaxObligations × (MaxBuckets-1) comparisons; see the package doc.

func Valid

method on Schedule
1func (s Schedule) Valid() bool
source

Valid reports whether the schedule is well-formed. A zero Schedule is not.

Dependencies

Imports 1

/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/aging

Imported by indexer

Source Files 2