Package depreciation is the carrying amount of a capital asset as a pure function of how many whole periods it has been in service.
A realm stores the plan and the date the asset was placed in service, and asks this package what it is worth now and what to charge for the span since it last asked. Nothing here reads the chain, holds state, or knows what a ledger is, so a realm can test a decade of its own depreciation without a node.
Example
1s, err := New(120000, 0, 120) // 120000 over 120 months
2n, _ := Periods(placedAt, now, Monthly)
3charge, _ := s.Charge(booked, n) // what to post
4carrying, _ := s.CarryingAt(n) // what it is worth
Why this is a function of n and not an accumulator
The obvious implementation stores the carrying amount, subtracts a per-period charge, and advances an anchor to now. It is wrong in a way that is specific and quiet: the per-period charge is an integer division, so each call truncates, and the part-period the call lands in is forfeited. Run twice as often and you forfeit twice as much — which for an asset register means *running the schedule more often depreciates less*. That is a misstatement of asset value, not a rounding preference.
p/moul/x/games/accrual names this bug and fixes it by advancing the anchor only by the whole periods it actually paid for, leaving the remainder on the clock, and it states the invariant that rules the bug out:
Example
1Advance(s, a, c) == Advance(Advance(s, a, b), b, c) for a <= b <= c
That invariant is adopted here, and the way it is obtained is different and stronger. Nothing in this package is an accumulator: Schedule.CarryingAt is a pure function of the period count, and Schedule.Charge is the difference of it at two points. So charges **telescope** —
Example
1Charge(a, b) + Charge(b, c) == Charge(a, c)
— by construction rather than by careful anchor arithmetic, and there is no stored carrying amount that could drift from the schedule at all. A realm that loses track of what it has booked can recompute it from the dates. [TestChargesTelescope] asserts it over pseudo-random partitions, because an implementation that truncates per call passes every round span and fails only on the ones that land mid-period.
The read path cannot disagree with the write path for the same reason: there is one function, and `Charge` is defined in terms of it.
Why a bounded loop is the right answer here and O(1) is not available
accrual must be closed form because elapsed time is unbounded: the player who returns after a month is exactly the one whose Render times out. Depreciation has no such exposure, and declining balance has no integer closed form — period n is the rate applied n times, with the floor taken each period, and taking the floor once at the end is a different and wrong number.
What bounds the loop instead is the asset's own **declared useful life**: Schedule.CarryingAt clamps at `Life` periods, `Life` is validated against MaxLife at construction, and the loop exits early once the salvage floor is reached. So the cost of a call is bounded by a number the caller declared and this package checked, not by how long ago the asset was bought. That is a different safety argument from closed-form and it is the one that makes declining balance implementable at all. Straight line *is* closed form and takes no loop.
What this package does not claim
- **No novelty for the straight-line arithmetic.** A quantity moving at a fixed rate per period toward a bound is shipped elsewhere in this ecosystem more than once; see DISCOVERY.md § 1. What was not found in the searched sources is the domain model — a salvage floor, a carrying amount, a declining-balance schedule over integers.
- **Nothing about money.** A carrying amount is a number in a plan. This package never sees a coin, and a realm using it must not infer custody from it.
- **No period convention beyond whole periods.** An asset placed in service mid-period depreciates from its placed-in-service date, not from the start of the calendar period it fell in. Half-year and mid-month conventions are a caller's job: shift `placedAt`. See § 4 of AUDIT.md.
- **No switch to straight line.** Textbook declining balance often crosses over to straight line once the latter charges more. This does not; see Schedule.CarryingAt on what happens at `Life` instead.
p/moul/x/games/accrual: /p/moul/x/games/accrual/v0