depreciation.gno
19.51 Kb · 465 lines
1// Package depreciation is the carrying amount of a capital asset as a pure
2// function of how many whole periods it has been in service.
3//
4// A realm stores the plan and the date the asset was placed in service, and
5// asks this package what it is worth now and what to charge for the span
6// since it last asked. Nothing here reads the chain, holds state, or knows
7// what a ledger is, so a realm can test a decade of its own depreciation
8// without a node.
9//
10// s, err := New(120000, 0, 120) // 120000 over 120 months
11// n, _ := Periods(placedAt, now, Monthly)
12// charge, _ := s.Charge(booked, n) // what to post
13// carrying, _ := s.CarryingAt(n) // what it is worth
14//
15// # Why this is a function of n and not an accumulator
16//
17// The obvious implementation stores the carrying amount, subtracts a per-period
18// charge, and advances an anchor to now. It is wrong in a way that is specific
19// and quiet: the per-period charge is an integer division, so each call
20// truncates, and the part-period the call lands in is forfeited. Run twice as
21// often and you forfeit twice as much — which for an asset register means
22// *running the schedule more often depreciates less*. That is a misstatement
23// of asset value, not a rounding preference.
24//
25// [p/moul/x/games/accrual] names this bug and fixes it by advancing the anchor
26// only by the whole periods it actually paid for, leaving the remainder on the
27// clock, and it states the invariant that rules the bug out:
28//
29// Advance(s, a, c) == Advance(Advance(s, a, b), b, c) for a <= b <= c
30//
31// That invariant is adopted here, and the way it is obtained is different and
32// stronger. Nothing in this package is an accumulator: [Schedule.CarryingAt]
33// is a pure function of the period count, and [Schedule.Charge] is the
34// difference of it at two points. So charges **telescope** —
35//
36// Charge(a, b) + Charge(b, c) == Charge(a, c)
37//
38// — by construction rather than by careful anchor arithmetic, and there is no
39// stored carrying amount that could drift from the schedule at all. A realm
40// that loses track of what it has booked can recompute it from the dates.
41// [TestChargesTelescope] asserts it over pseudo-random partitions, because an
42// implementation that truncates per call passes every round span and fails
43// only on the ones that land mid-period.
44//
45// The read path cannot disagree with the write path for the same reason: there
46// is one function, and `Charge` is defined in terms of it.
47//
48// # Why a bounded loop is the right answer here and O(1) is not available
49//
50// accrual must be closed form because elapsed time is unbounded: the player
51// who returns after a month is exactly the one whose Render times out.
52// Depreciation has no such exposure, and declining balance has no integer
53// closed form — period n is the rate applied n times, with the floor taken
54// each period, and taking the floor once at the end is a different and wrong
55// number.
56//
57// What bounds the loop instead is the asset's own **declared useful life**:
58// [Schedule.CarryingAt] clamps at `Life` periods, `Life` is validated against
59// [MaxLife] at construction, and the loop exits early once the salvage floor
60// is reached. So the cost of a call is bounded by a number the caller declared
61// and this package checked, not by how long ago the asset was bought. That is
62// a different safety argument from closed-form and it is the one that makes
63// declining balance implementable at all. Straight line *is* closed form and
64// takes no loop.
65//
66// # What this package does not claim
67//
68// - **No novelty for the straight-line arithmetic.** A quantity moving at a
69// fixed rate per period toward a bound is shipped elsewhere in this
70// ecosystem more than once; see DISCOVERY.md § 1. What was not found in the
71// searched sources is the domain model — a salvage floor, a carrying
72// amount, a declining-balance schedule over integers.
73// - **Nothing about money.** A carrying amount is a number in a plan. This
74// package never sees a coin, and a realm using it must not infer custody
75// from it.
76// - **No period convention beyond whole periods.** An asset placed in
77// service mid-period depreciates from its placed-in-service date, not from
78// the start of the calendar period it fell in. Half-year and mid-month
79// conventions are a caller's job: shift `placedAt`. See § 4 of AUDIT.md.
80// - **No switch to straight line.** Textbook declining balance often
81// crosses over to straight line once the latter charges more. This does
82// not; see [Schedule.CarryingAt] on what happens at `Life` instead.
83//
84// [p/moul/x/games/accrual]: /p/moul/x/games/accrual/v0
85package depreciation
86
87import "errors"
88
89// MaxLife bounds the declared useful life in whole periods, and so bounds the
90// declining-balance loop in [Schedule.CarryingAt]. 1200 is a century of
91// monthly periods, which is past the point where any real schedule stops
92// being meaningful.
93//
94// What the bound costs is stated here rather than waved at, because the
95// earlier wording claimed the loop "is not a gas hazard even in a Render" and
96// no gas was ever measured. The figures that were: CarryingAt runs the loop n
97// times with n < Life, so at most MaxLife-1 = 1199 iterations per call, and
98// [Schedule.Charge] evaluates CarryingAt twice, so one Charge can run 2398.
99// The early exit at the salvage floor is an optimisation and not part of the
100// bound — a schedule whose per-period charge truncates to zero
101// (Cost*RateNum < RateDen) never moves off Cost, never reaches the floor, and
102// so runs the full 1199 on every call for the asset's whole life. A realm
103// that walks many assets in one transaction, or renders them, must size that
104// against the gas it has. This package bounds the work; it does not price it.
105const MaxLife = 1200
106
107// Period lengths in seconds, for a caller that keeps time in unix seconds.
108// They are conveniences and not a calendar: Monthly is 30 days and Yearly is
109// 365, so neither tracks month lengths or leap years. A caller that needs a
110// real calendar must compute its own period count and pass it to the
111// CarryingAt/Charge pair directly, which take period counts and not times.
112const (
113 Daily int64 = 86400
114 Monthly int64 = 30 * Daily
115 Yearly int64 = 365 * Daily
116)
117
118var (
119 // ErrBadCost is returned when cost is not positive. A zero-cost asset
120 // has nothing to depreciate and makes Basis degenerate.
121 ErrBadCost = errors.New("depreciation: cost must be positive")
122
123 // ErrBadSalvage is returned when salvage is negative, or is not less
124 // than cost. Salvage equal to cost is a non-depreciating asset, which
125 // is legitimate bookkeeping but is not a schedule: there is no charge
126 // to post, ever, and silently accepting it would have a realm open
127 // accounts and post nothing.
128 ErrBadSalvage = errors.New("depreciation: salvage must be in [0, cost)")
129
130 // ErrBadLife is returned when the useful life is not positive or
131 // exceeds MaxLife.
132 ErrBadLife = errors.New("depreciation: life must be in [1, MaxLife]")
133
134 // ErrBadMethod is returned for a method outside the declared set.
135 ErrBadMethod = errors.New("depreciation: unknown method")
136
137 // ErrBadRate is returned when a declining-balance factor is not a
138 // proper fraction in (0, 1). A rate of 1 writes the asset off in one
139 // period and a rate of 0 never charges anything; both are better said
140 // another way, so neither is accepted here.
141 ErrBadRate = errors.New("depreciation: rate must satisfy 0 < num < den")
142
143 // ErrBadPeriod is returned when a period length is not positive.
144 ErrBadPeriod = errors.New("depreciation: period must be positive")
145
146 // ErrBackwards is returned when now falls before the placed-in-service
147 // time. Time moving backwards is a caller bug — a stored timestamp from
148 // another clock, a test that rewound — and treating it as zero elapsed
149 // would hide it.
150 ErrBackwards = errors.New("depreciation: now falls before placedAt")
151
152 // ErrBadSpan is returned by Charge when the span runs backwards, which
153 // would make a depreciation charge negative and credit an expense.
154 ErrBadSpan = errors.New("depreciation: charge span must not run backwards")
155
156 // ErrOverflow is returned when the arithmetic would wrap. Most
157 // overflow is excluded at construction instead; see New.
158 ErrOverflow = errors.New("depreciation: int64 overflow")
159)
160
161// The bounds are written as literals rather than as the usual `int64(1)<<63 - 1`
162// idiom, which does not compile: the shift is evaluated in the already-typed
163// int64 and overflows before the subtraction can bring it back in range. It is
164// a compile error here rather than a wrong number, but it is the same shape as
165// the arithmetic this package exists to get right, so it is worth naming.
166const (
167 maxInt64 int64 = 9223372036854775807
168 maxInt32 int64 = 2147483647
169)
170
171// Method is how the basis is spread across the useful life.
172type Method uint8
173
174const (
175 // StraightLine charges the basis evenly across the life. Closed form.
176 StraightLine Method = 1
177
178 // DecliningBalance charges a fixed fraction of the remaining carrying
179 // amount each period, so early periods charge more. Not closed form
180 // over integers; see the package doc on why the loop is bounded.
181 DecliningBalance Method = 2
182)
183
184// Valid reports whether m is a method this package implements.
185func (m Method) Valid() bool { return m == StraightLine || m == DecliningBalance }
186
187// String names the method. The zero Method is "invalid" rather than empty, so
188// a caller that renders it without checking Valid prints something that reads
189// as wrong instead of something that reads as missing.
190func (m Method) String() string {
191 switch m {
192 case StraightLine:
193 return "straight-line"
194 case DecliningBalance:
195 return "declining-balance"
196 }
197 return "invalid"
198}
199
200// Schedule is the depreciation plan for one asset. It is a value, carries no
201// state about what has been booked, and is safe to copy. Construct with
202// [New] or [NewDeclining] so the invariants — including the overflow
203// preconditions — are checked once rather than on every call.
204type Schedule struct {
205 // Cost is the depreciable basis at placed-in-service.
206 Cost int64
207
208 // Salvage is the floor. The carrying amount never falls below it and
209 // reaches it exactly at Life.
210 Salvage int64
211
212 // Life is the useful life in whole periods.
213 Life int32
214
215 // Method is how the basis is spread.
216 Method Method
217
218 // RateNum and RateDen are the declining-balance factor as a proper
219 // fraction. Both are zero for StraightLine.
220 RateNum int64
221 RateDen int64
222}
223
224// New validates and returns a straight-line schedule.
225func New(cost, salvage int64, life int32) (Schedule, error) {
226 s := Schedule{Cost: cost, Salvage: salvage, Life: life, Method: StraightLine}
227 if err := s.check(); err != nil {
228 return Schedule{}, err
229 }
230 return s, nil
231}
232
233// NewDeclining validates and returns a declining-balance schedule charging
234// num/den of the remaining carrying amount each period. Double-declining over
235// a 10-period life is num=2, den=10.
236func NewDeclining(cost, salvage int64, life int32, num, den int64) (Schedule, error) {
237 s := Schedule{
238 Cost: cost, Salvage: salvage, Life: life,
239 Method: DecliningBalance, RateNum: num, RateDen: den,
240 }
241 if err := s.check(); err != nil {
242 return Schedule{}, err
243 }
244 return s, nil
245}
246
247// check validates every field and, critically, the two overflow
248// preconditions. Establishing them here is what lets CarryingAt and Charge be
249// total functions on a constructed Schedule: a zero value still fails, which
250// is why every method calls check rather than trusting its receiver.
251func (s Schedule) check() error {
252 if s.Cost <= 0 {
253 return ErrBadCost
254 }
255 if s.Salvage < 0 || s.Salvage >= s.Cost {
256 return ErrBadSalvage
257 }
258 if s.Life <= 0 || int64(s.Life) > MaxLife {
259 return ErrBadLife
260 }
261 if !s.Method.Valid() {
262 return ErrBadMethod
263 }
264 switch s.Method {
265 case StraightLine:
266 // Basis*n is the only product, and n <= Life.
267 if s.Basis() > maxInt64/int64(s.Life) {
268 return ErrOverflow
269 }
270 case DecliningBalance:
271 if s.RateDen <= 0 || s.RateNum <= 0 || s.RateNum >= s.RateDen {
272 return ErrBadRate
273 }
274 // c*RateNum is the only product and c <= Cost, so Cost*RateDen
275 // bounds it. Checking against RateDen rather than RateNum is
276 // deliberate: it is the looser quantity, so the precondition
277 // stays true if a later change moves the multiplication.
278 if s.Cost > maxInt64/s.RateDen {
279 return ErrOverflow
280 }
281 }
282 return nil
283}
284
285// Valid reports whether the schedule is well-formed. A zero Schedule is not.
286func (s Schedule) Valid() bool { return s.check() == nil }
287
288// Basis is Cost - Salvage: the total that will ever be charged across the
289// whole life, under either method.
290//
291// It does not validate, and it is the only exported method here that does
292// not. That is forced rather than chosen: check() calls Basis to establish
293// one of its own overflow preconditions, so Basis cannot call check(). It is
294// therefore a bare subtraction over whatever fields it was handed, and on an
295// ill-formed schedule it returns an arbitrary number — Schedule{Cost:
296// maxInt64, Salvage: -1} wraps to minInt64. A zero result means "invalid, or
297// zero cost", never "fully depreciated"; depreciation booked to date is
298// [Schedule.AccumulatedAt], which does validate. Guard a caller-supplied
299// schedule with [Schedule.Valid] before quoting Basis from it.
300func (s Schedule) Basis() int64 { return s.Cost - s.Salvage }
301
302// CarryingAt returns the carrying amount after n whole periods in service.
303//
304// It is monotone non-increasing in n, equals Cost at n <= 0, and equals
305// Salvage exactly at every n >= Life. That last clamp is a declared
306// convention and is doing real work under DecliningBalance, which approaches
307// the floor asymptotically and would otherwise never reach it: the final
308// period absorbs whatever remains. The consequence is worth stating because it
309// is visible in the numbers — a declining-balance schedule's last charge can
310// be much larger than the one before it. Textbooks usually avoid that by
311// crossing over to straight line partway; this package does not, because the
312// crossover rule is a policy choice that belongs to the caller and burying it
313// here would make the schedule unexplainable from its own fields.
314//
315// A declining-balance schedule whose per-period charge truncates to zero
316// never depreciates at all. The charge is (c*RateNum)/RateDen in integers, so
317// whenever Cost*RateNum < RateDen it is 0, c never moves off Cost, and
318// CarryingAt returns Cost at every n below Life and Salvage at Life — the
319// whole of the basis falls in the last period. That is integer truncation
320// behaving correctly and it is accepted rather than guarded, because the
321// alternative is a minimum-charge rule, which is a policy a caller may
322// reasonably want either way and cannot be inferred from the fields. It is
323// documented because it is invisible in the ordinary numbers and is the one
324// shape that also defeats the loop's early exit; see [MaxLife] on the cost.
325//
326// Under StraightLine the result is closed form. Under DecliningBalance it
327// costs O(min(n, Life)) iterations, bounded by MaxLife and exiting early at
328// the floor; see the package doc.
329func (s Schedule) CarryingAt(n int32) (int64, error) {
330 if err := s.check(); err != nil {
331 return 0, err
332 }
333 if n <= 0 {
334 return s.Cost, nil
335 }
336 if n >= s.Life {
337 return s.Salvage, nil
338 }
339 if s.Method == StraightLine {
340 // Basis*n cannot overflow: check() established
341 // Basis <= maxInt64/Life and n < Life.
342 return s.Cost - (s.Basis()*int64(n))/int64(s.Life), nil
343 }
344 c := s.Cost
345 for i := int32(0); i < n; i++ {
346 // c*RateNum cannot overflow: check() established
347 // Cost <= maxInt64/RateDen, RateNum < RateDen, and c <= Cost.
348 charge := (c * s.RateNum) / s.RateDen
349 if c-charge < s.Salvage {
350 charge = c - s.Salvage
351 }
352 c -= charge
353 if c <= s.Salvage {
354 return s.Salvage, nil
355 }
356 }
357 return c, nil
358}
359
360// AccumulatedAt returns accumulated depreciation after n whole periods: the
361// contra-asset balance, Cost - CarryingAt(n). It is defined in terms of
362// CarryingAt so the two can never disagree, which is the same reason Charge
363// is.
364func (s Schedule) AccumulatedAt(n int32) (int64, error) {
365 c, err := s.CarryingAt(n)
366 if err != nil {
367 return 0, err
368 }
369 return s.Cost - c, nil
370}
371
372// Charge returns the depreciation expense for moving a schedule from `from`
373// whole periods booked to `to`.
374//
375// It is the difference of CarryingAt at the two points, which makes charges
376// telescope exactly:
377//
378// Charge(a, b) + Charge(b, c) == Charge(a, c) for a <= b <= c
379//
380// So how often a realm runs the schedule cannot change the total it books, and
381// a realm that has booked through period `from` can post Charge(from, now)
382// with no stored carrying amount to drift. The result is never negative; a
383// span that runs backwards is ErrBadSpan rather than a credit to an expense
384// account.
385func (s Schedule) Charge(from, to int32) (int64, error) {
386 if err := s.check(); err != nil {
387 return 0, err
388 }
389 if to < from {
390 return 0, ErrBadSpan
391 }
392 before, err := s.CarryingAt(from)
393 if err != nil {
394 return 0, err
395 }
396 after, err := s.CarryingAt(to)
397 if err != nil {
398 return 0, err
399 }
400 // before >= after because CarryingAt is monotone non-increasing, so the
401 // difference is non-negative and cannot overflow: both lie in
402 // [Salvage, Cost].
403 return before - after, nil
404}
405
406// Periods returns the whole periods elapsed between placedAt and now.
407//
408// A partial period in progress is not counted and is not lost: it is counted
409// by a later call, once it completes. That is what makes the telescoping
410// property above reach the right total however the caller slices time.
411//
412// The span is measured in uint64 on purpose, and the reason is the whole
413// correctness argument. `now >= placedAt` is already enforced above, so the
414// true elapsed span is non-negative; its largest value is
415// maxInt64 - minInt64 = 2^64 - 1, which does not fit in an int64 and fits
416// *exactly* in a uint64. int64 subtraction wraps two's-complement, so the bit
417// pattern of now - placedAt is already the low 64 bits of the true difference,
418// and reading that pattern as a uint64 recovers the difference with no loss,
419// no clamp and no reachable wrap. ErrOverflow therefore means exactly one
420// thing here — the quotient does not fit in an int32 — and the subtraction
421// needs no guard at all.
422//
423// The guard it replaces, `placedAt < 0 && now > 0 && elapsed < 0`, was wrong
424// in *both* directions, measured by exhaustive differential sweep against
425// exact arithmetic rather than reasoned about: it returned (0, nil) for
426// Periods(minInt64, 0, 1) and even a negative count for
427// Periods(minInt64, 0, 2^62), and it rejected representable answers such as
428// Periods(-1, maxInt64, maxInt64), whose count is 1. See § 5 of AUDIT.md.
429func Periods(placedAt, now, period int64) (int32, error) {
430 if period <= 0 {
431 return 0, ErrBadPeriod
432 }
433 if now < placedAt {
434 return 0, ErrBackwards
435 }
436 n := (uint64(now) - uint64(placedAt)) / uint64(period)
437 if n > uint64(maxInt32) {
438 return 0, ErrOverflow
439 }
440 return int32(n), nil
441}
442
443// FullyDepreciatedAt returns the time at which the asset first carries only
444// its salvage value, for a realm that wants to tell a reader when the charges
445// stop. It is placedAt + Life*period.
446//
447// Under DecliningBalance the carrying amount may sit very close to salvage
448// well before this time, and under both methods it is not at salvage until
449// this time, because Life is what the clamp in CarryingAt uses.
450func (s Schedule) FullyDepreciatedAt(placedAt, period int64) (int64, error) {
451 if err := s.check(); err != nil {
452 return 0, err
453 }
454 if period <= 0 {
455 return 0, ErrBadPeriod
456 }
457 if int64(s.Life) > maxInt64/period {
458 return 0, ErrOverflow
459 }
460 span := int64(s.Life) * period
461 if placedAt > maxInt64-span {
462 return 0, ErrOverflow
463 }
464 return placedAt + span, nil
465}