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.

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}