P3: depreciation straight-line by days through the final month, schedules by day (defects 22, 9, D-37)
The final month takes what the months before leave, spread over its days; a disposed asset stops on its disposal day. A skipped month is caught up in the month the run is made; a reversed run un-posts its month. New schedules spread by day; equal-months schedules must start on the 1st. Amounts round to the currency's own minor unit. Kani and Lean proofs; an exhaustive search over runs, reversals and disposals. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
69 files changed
+10529
-841
No files matched your search
+133
-14
@@ -6,9 +6,9 @@ ways, each covering what the others can't:
|
||||
|
||||
| Layer | Tool | What it proves | Scope |
|
||||
|---|---|---|---|
|
||||
| **Code** | [Kani](https://model-checking.github.io/kani/) (bounded model checking) | The engine's own Rust kernel is correct and never panics or overflows | Every input within the bounds: up to 4 lines, 3 accounts, amounts to 2^40 |
|
||||
| **Code** | [Kani](https://model-checking.github.io/kani/) (bounded model checking) | The engine's own Rust kernel is correct and never panics or overflows | Every input within the bounds: up to 4 lines, 3 accounts, amounts to 2^40; proration by days up to 3 month parts (bounds below) |
|
||||
| **Laws** | [Lean 4](https://lean-lang.org) (machine-checked proof) | The laws hold for books of any size, after any sequence of commands | Unbounded: any number of entries, accounts, periods, steps |
|
||||
| **Bridge** | proptest | The kernel on `Decimal` (what the engine runs) agrees with the kernel on `i64` (what Kani proves) | 2,000 random cases per property |
|
||||
| **Bridge** | proptest | The kernel on `Decimal` (what the engine runs) agrees with the kernel on `i64` (what Kani proves); proration's `i128` (the engine's) with its `i64` and `i32` | 2,000 random cases per property |
|
||||
|
||||
Run all of it with `just prove` (after `mise install`; see the end).
|
||||
|
||||
@@ -26,12 +26,14 @@ period closing, `periods::allocate`, and the trial balance. There is no
|
||||
second copy of the arithmetic for proofs to drift from.
|
||||
|
||||
Kani compiles the same functions at `i64` (exact integer money in the
|
||||
smallest unit) and explores every input in the bounds. (Grouping by
|
||||
smallest unit; the four proration charge harnesses at `i32`) and
|
||||
explores every input in the bounds. (Grouping by
|
||||
account is proved through `movements_into`, the allocation-free core
|
||||
that `movements` wraps: symbolic heap growth is beyond what the model
|
||||
checker explores in reasonable time.) The Lean model in
|
||||
`verify/lean` restates each function over `Int` and proves the laws with
|
||||
no bounds at all.
|
||||
`verify/lean` restates each function over `Int` (proration over `Nat`,
|
||||
both of `prorated_through`'s branches) and proves the laws with no bounds
|
||||
at all.
|
||||
|
||||
## The laws
|
||||
|
||||
@@ -306,6 +308,117 @@ their numbers are assigned when P3 lands:
|
||||
`a_reversal_nets_every_bucket_to_zero` (ledger-core
|
||||
`tests/form1099.rs`)
|
||||
|
||||
### Proration by days, catching up, truing up (P3, defects 22 and 9)
|
||||
|
||||
Depreciation (rules.md B-15) and daily prepaid and deferred-revenue
|
||||
schedules (B-16) spread a total over a span of days with one kernel
|
||||
function, `kernel::prorate_by_days` (each month's share through
|
||||
`kernel::prorated_through`), in exact integer arithmetic over a common
|
||||
unit, `MONTH_L` = lcm(28, 29, 30, 31) = 377,580 parts of a month, so a day
|
||||
of any month is a whole number of them.
|
||||
|
||||
22. **The parts of a span sum to its total exactly, none is negative, and
|
||||
the last is the total less the others** (plan/served.md P3 defect 9:
|
||||
"parts sum to the total; the last part absorbs the remainder"). Every
|
||||
split whose parts but the last fit in the span is computed; only parts
|
||||
no calendar has are refused.
|
||||
- Kani `prorate_parts_sum_to_total`, `prorate_last_part_absorbs_the_remainder` (a
|
||||
corollary, kept because the plan names the law) · Lean
|
||||
`Prorate.kernel_parts_total`, `Prorate.kernel_parts_nonneg` (the
|
||||
kernel's split, any number of parts and any sizes), `Prorate.cum_dvd`
|
||||
(a total in whole units is charged, so split, in whole units), from
|
||||
`Prorate.prorate_total`, `Prorate.prorate_nonneg`,
|
||||
`Prorate.parts_telescope` · bridge `prorate_agrees_at_i64_and_i128`
|
||||
23. **Each part but the last is its days times the monthly rate within one
|
||||
minor unit** (P3 defect 9: "each part equals days × rate within 0.01",
|
||||
stated for any currency's minor unit). Each cumulative charge is its
|
||||
exact share rounded half up to a multiple of the unit (a tie goes up;
|
||||
the total itself when that rounds above it): Kani of the kernel at its
|
||||
bounds, Lean of both of the kernel's branches at any size
|
||||
(`roundUnit_near`, `cum_near`; `chargeMid_eq_cum`, `chargeLast_eq_cum`
|
||||
say the kernel's two formulas are the model's). Two such halves make
|
||||
the part's one unit (`part_near`, at any size, for any parts).
|
||||
- Kani `a_months_charge_is_within_half_a_cent`,
|
||||
`a_months_charge_in_tenths_is_within_half_a_cent`,
|
||||
`the_last_months_charge_is_within_half_a_cent`,
|
||||
`the_last_months_charge_in_tenths_is_within_half_a_cent` · Lean
|
||||
`Prorate.roundUnit_near`, `Prorate.cum_near`, `Prorate.part_near`,
|
||||
`Prorate.part_within_a_unit` · bridge `prorated_through_agrees_at_i32_and_i128`
|
||||
24. **The charge through a day never falls, never exceeds the total, is
|
||||
continuous where one month ends and the next begins, and is the total
|
||||
on the last day**; malformed input (a negative total, no months, a
|
||||
unit that isn't positive, a part no month has, a day past the part, a
|
||||
weight outside the span) is refused, never a panic.
|
||||
- Lean `Prorate.cum_mono`, `Prorate.cum_le`, `Prorate.cum_full` (the
|
||||
model, any span), `Prorate.chargeLast_mono` and
|
||||
`Prorate.chargeLast_start` (the kernel's last month, by days, from
|
||||
where the month before ends: rounding is unchanged when an amount and
|
||||
its denominator are scaled together, `Prorate.roundUnit_scale`) · unit
|
||||
`the_charge_never_falls_and_is_continuous_over_real_calendars`
|
||||
(the kernel, exhaustively: every span of 1 to 3 months from every day
|
||||
of a common and a leap year, walked day by day, eight totals, both
|
||||
units) · Kani's four charge harnesses (bounded: never negative, never
|
||||
above the total, the total on the last day),
|
||||
`prorated_through_refuses_malformed_input` · property
|
||||
`prop_depreciation_is_monotone_and_bounded` (the engine)
|
||||
25. **A catch-up run never carries the books past the schedule** at a
|
||||
month end already posted, and posts everything due through its month
|
||||
when no run comes after it (plan/served.md P3, settled in design: a
|
||||
month skipped is caught up in the month the run is made); every
|
||||
month's figure at once (`catch_ups`) is each suffix's catch-up. What
|
||||
the books carry at each month end is read entry by entry, each run and
|
||||
each reversal in its own month (`assets::depreciation_effects`), so the
|
||||
kernel's law is a law of the books' month ends, not of the runs'
|
||||
periods (a run reversed after its month ended is never run twice; a
|
||||
re-instated run never carries the books past the schedule).
|
||||
- Kani `catch_up_never_overtakes_the_schedule`,
|
||||
`catch_ups_is_every_suffixs_catch_up` · bridge
|
||||
`catch_up_and_true_up_agree` · unit `catch_ups_is_every_suffixs_catch_up`
|
||||
· property `prop_run_amounts_are_each_months_run_amount`
|
||||
· store `a_run_reversed_after_its_month_is_caught_up_in_the_reversals_month`,
|
||||
`reinstating_a_run_a_later_run_caught_up_is_refused`
|
||||
26. **A disposal's true-up reaches the schedule** through its day from what
|
||||
the books carry, up or down, never both (`true_up` is `columns` of the
|
||||
gap).
|
||||
- Kani `true_up_reaches_the_target` · bridge `catch_up_and_true_up_agree`
|
||||
|
||||
Bounds (Kani): the split harnesses, at `i64`: up to 3 month parts, 1 to
|
||||
3 months, each month 28 to 31 days with 1 to all of them in the span,
|
||||
totals to 2^16 units (they also passed at 2^20, 2026-10-20), a unit of
|
||||
1 or 10. The charge harnesses, at `i32` (the kernel is generic over
|
||||
`Units`; `i32` so the solver can relate a product to a quotient): 1 or
|
||||
2 months, any one month part and weight before it, totals to 2,048 units
|
||||
(a month but the last) or 64 (the last month, whose numerator carries the
|
||||
month's days), each unit in its own harness;
|
||||
the malformed-input harness: totals to 2^20 either sign, months 0 to 3,
|
||||
days, month lengths and k 0 to 40, a weight before of up to 4 ×
|
||||
MONTH_L either sign, units -10 to 10; catch-up (and every suffix's) over
|
||||
up to 3 month ends, and the true-up, at amounts to 2^40.
|
||||
|
||||
What the solver could not do, and what carries it instead: a harness
|
||||
relating two symbolic divisions (a part against days × rate directly; a
|
||||
charge never falling within a month; the charge through a month's last
|
||||
day against the next month's through none of its days) ran past twenty
|
||||
minutes each, at 2^20 and again at 2^16, with CaDiCaL and with Kissat,
|
||||
and was stopped (2026-10-20); so did the one-division charge bound at
|
||||
`i64` (a checked 64-bit product is a 128-bit multiplier to the solver).
|
||||
Each such relation is nonlinear in the divisor. So Kani proves the
|
||||
one-division fact at `i32` (each charge within half a unit of its exact
|
||||
share: one to seven minutes a harness), the bridge ties the `i32` kernel
|
||||
to the engine's `i128` one, and Lean proves every one of these relations
|
||||
at any size, of the kernel's own two formulas: the model's charge is
|
||||
`cum t unit V w` (the total times `w/V`, rounded half up to a multiple of
|
||||
`unit`, capped at the total, and the total at `w = V`), and
|
||||
`chargeMid`/`chargeLast` are `prorated_through`'s two branches written
|
||||
out, proved equal to `cum` over `W` = months × MONTH_L and over `W` × the
|
||||
last month's days, with the two meeting where the last month starts
|
||||
(`roundUnit_scale`). The one hypothesis is the calendar's: every part
|
||||
but the last ends short of the span (`e < W`), which a walk from any
|
||||
in-service date keeps (B-15: a first month of at most one, then whole
|
||||
months); an exhaustive test walks the kernel itself over every
|
||||
1-to-3-month span of two calendar years, and the bridge and the exact
|
||||
unit tests check the rest.
|
||||
|
||||
## What the proofs found
|
||||
|
||||
The engine already had 190+ tests, property tests, mutation testing,
|
||||
@@ -363,8 +476,11 @@ can also prove false claims. So:
|
||||
entry, a reversal on one account with several lines, three distinct
|
||||
accounts, a balanced three-account chart, a close to two members, a
|
||||
part-cash part-noncash investing entry, cash settling two sections at
|
||||
once, a mixed entry reversed, property contributed for capital. `just
|
||||
prove` fails if any cover is unsatisfiable.
|
||||
once, a mixed entry reversed, property contributed for capital; a split
|
||||
of three parts, a half-unit tie rounding up, a charge capped at a total
|
||||
finer than its unit, a catch-up across three month ends and distinct
|
||||
ones for every suffix, a true-up each way. `just prove` fails if any
|
||||
cover is unsatisfiable.
|
||||
|
||||
## What is and isn't proved
|
||||
|
||||
@@ -373,10 +489,12 @@ Be precise about the claims:
|
||||
- **Kani is bounded.** It proves the kernel for every input up to 4 lines
|
||||
over 3 accounts and amounts to 2^40 units (the accounting equation end
|
||||
to end: up to 3 accounts; the cash flow entry harnesses: up to 3 lines,
|
||||
each tagged any of 4 ways). Lean proves the laws for any
|
||||
size, but of a *model*. The model is a direct restatement of the kernel,
|
||||
and the bridge property test ties the `Decimal` instantiation to the
|
||||
`i64` one Kani checked.
|
||||
each tagged any of 4 ways), and the proration by days within the bounds
|
||||
above. Lean proves the laws for any size, but of a *model*. The model
|
||||
is a direct restatement of the kernel (for proration, its two formulas
|
||||
written out and proved equal to the model's charge), and the bridge
|
||||
property tests tie the `Decimal` (and `i128`) instantiation to the
|
||||
`i64` (and `i32`) one Kani checked.
|
||||
- **`Decimal` itself is trusted.** `rust_decimal` is exact for these
|
||||
sizes: 96-bit integers scaled by a power of ten. With lines capped at
|
||||
one quadrillion and ten decimal places, every sum is exact while the
|
||||
@@ -389,9 +507,10 @@ Be precise about the claims:
|
||||
`tests/properties.rs` (trial balance, balance sheet, reversals,
|
||||
immutability) and the golden files.
|
||||
- **Out of scope:** currency (the ledger is single-currency, enforced
|
||||
structurally), A/R and A/P documents, payroll, depreciation schedules
|
||||
(those have their own property tests), and persistence (the journal
|
||||
store's hash chain is tested in `ledger-store`).
|
||||
structurally), A/R and A/P documents, payroll, the calendar walk around
|
||||
the proration kernel (`calendar::month_parts`, `assets::depreciation_schedule`,
|
||||
`schedules::daily_plan`: property tests, `tests/properties.rs`, P3), and
|
||||
persistence (the journal store's hash chain is tested in `ledger-store`).
|
||||
- **The cash flow model tags lines per entry with any function** meeting
|
||||
the engine's guarantees (the cash tag follows the account; revenue and
|
||||
expense lines are operating or investing; a reversal takes its
|
||||
|
||||
Reference in new issue
Block a user