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
+88
-25
@@ -447,7 +447,14 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`.
|
||||
when the shares don't add up to what the entry moved, `parties_error`
|
||||
says why and confirming fails with code `party_shares`.
|
||||
- `books.reverse_entry { entry_id, date, description, confirmed }` → same
|
||||
preview/confirm pattern as post.
|
||||
preview/confirm pattern as post. A reversed depreciation run or schedule
|
||||
month is un-posted (its month is due again); refused, at the preview
|
||||
already (P3): an asset's disposal entry (it would stay disposed), a
|
||||
depreciation run of a disposed asset (its disposal took it as final),
|
||||
and a reversal whose reversal would post a depreciation period or a
|
||||
schedule month a second time (naming the entry that posts it now), or
|
||||
re-instate a run a later run caught up (carrying the books past the
|
||||
schedule).
|
||||
- `books.get_entry { entry_id }` → full entry with lines, receipts, and
|
||||
its `vendor`, `party_id`, and `party` (the party's label, when it names
|
||||
exactly one) tags, `parties` / `parties_error` as in the `post_entry`
|
||||
@@ -556,8 +563,13 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`.
|
||||
## Tools — fixed assets
|
||||
|
||||
Straight-line **book** depreciation only; tax depreciation stays with the
|
||||
CPA. Partial first/final months prorated by days; the final month trues up
|
||||
so the lifetime total is exactly (cost − salvage).
|
||||
CPA. Depreciation accrues by the day (rules.md B-15): a partial first
|
||||
month, the life's final month (which takes what the others leave, evenly
|
||||
over its days), and any month a schedule stops in (through a date, or a
|
||||
disposed asset's disposal day, held) are prorated by days, never plugged;
|
||||
the lifetime total is exactly (cost − salvage), rounded to the currency's
|
||||
minor unit. A new asset's cost and salvage are in the minor unit, and its
|
||||
life's last day is 9999-12-31 at the latest.
|
||||
|
||||
- `books.register_asset { description, purchase_date, cost, salvage_value?,
|
||||
useful_life_months, cost_account, accum_depr_account, expense_account,
|
||||
@@ -573,19 +585,50 @@ so the lifetime total is exactly (cost − salvage).
|
||||
tags }`: an Avery 5160/8160 label sheet (PDF): company, tag, QR code of
|
||||
the tag, description, serial. Default: every active tagged asset; `skip`
|
||||
reuses a partly used sheet.
|
||||
- `books.asset_schedule { asset_id | tag }`
|
||||
→ `{ status, schedule: [{ period, amount, accumulated, book_value }] }`.
|
||||
- `books.list_assets {}` → assets with status and current book values.
|
||||
- `books.asset_schedule { asset_id | tag, through? }`
|
||||
→ `{ status, schedule: [{ period, amount, accumulated, book_value }] }`;
|
||||
a disposed asset's ends on its disposal day. A schedule an earlier
|
||||
version's asset can't compute is a tool error saying why, never a
|
||||
panic.
|
||||
- `books.list_assets {}` → assets with status, accumulated depreciation
|
||||
by schedule and book value as of today (a disposed asset's through its
|
||||
disposal day); `accumulated` and `book_value` are null, with
|
||||
`schedule_error` saying why, for an asset whose schedule can't be
|
||||
computed (one bad asset never fails the list).
|
||||
- `books.run_depreciation { period: "YYYY-MM", asset_id? }`
|
||||
→ per-asset `{ entry_id, amount, already_posted }`, plus `skipped` for
|
||||
assets outside their useful life. Idempotent per asset and period.
|
||||
→ `runs: [{ asset_id, description, period, amount, caught_up, entry_id,
|
||||
already_posted }]` and `skipped: [{ asset_id, description, reason }]`.
|
||||
A run posts what the schedule says is due through the month's end, so a
|
||||
month skipped or reversed is caught up in the month run (`caught_up`:
|
||||
the part beyond the month's own; a material miss is a restatement for
|
||||
the CPA, ASC 250), and never more than the schedule at a later month
|
||||
end. What the books carry is read entry by entry, each in its own
|
||||
month, so a month whose run was reversed after it ended posts nothing
|
||||
(it carried the month then; the run of the reversal's month catches it
|
||||
up). Idempotent per asset and period. Nothing due (the
|
||||
month rounds to nothing, a later run caught it up, the life is over and
|
||||
posted) posts nothing and is listed under `skipped` with why, as are a
|
||||
run-all's assets not yet in service and any whose schedule can't be
|
||||
computed. A run before the in-service date is refused.
|
||||
- `books.dispose_asset { asset_id | tag, date, proceeds, proceeds_account,
|
||||
gain_loss_account, buyer?, confirmed?, receipt_attachment_ids? }` →
|
||||
without
|
||||
confirmation: `{ status: "preview", ... }` and posts nothing; with
|
||||
`confirmed: true`: auto-posts missing final-period depreciation, then
|
||||
posts the balanced disposal entry. Subject to receipt policy (bill of
|
||||
sale); monthly depreciation is exempt as an allocation. `buyer` (a
|
||||
without confirmation: `{ status: "preview", accumulated, book_value,
|
||||
proceeds, gain_loss, final_depreciation, excess_depreciation,
|
||||
catch_up_depreciation, lines, receipt_required }` and posts nothing;
|
||||
with `confirmed: true`: auto-posts the final depreciation (through the
|
||||
disposal day, held), then posts the balanced disposal entry. Book value
|
||||
is cost less the schedule through the disposal day, whatever the runs
|
||||
posted: depreciation a run posted past it (the disposal month's run is
|
||||
dated its end) is credited back to depreciation expense in the disposal
|
||||
entry (`excess_depreciation`); depreciation still due when the disposal
|
||||
month already has a run is debited there (`catch_up_depreciation`); the
|
||||
entry clears everything the runs carry. Refused, at the preview already,
|
||||
unless every run carries at the end of the disposal's month what it
|
||||
carries now: a run of a later month that stands (reverse it first), or
|
||||
a reversal (or re-instatement) of an earlier run dated after the
|
||||
disposal's month that a later one hasn't undone (undo it, on any open
|
||||
day). Subject to receipt policy (bill
|
||||
of sale); monthly depreciation is exempt as an allocation. `buyer` (a
|
||||
party) is the disposal entry's party; under `[books] parties =
|
||||
"require"` a sale with proceeds without one is refused before anything
|
||||
posts (`party_required`); a retirement with no proceeds and no buyer is
|
||||
@@ -603,27 +646,47 @@ so the lifetime total is exactly (cost − salvage).
|
||||
## Tools — schedules and recurring entries
|
||||
|
||||
Amortization spreads a prepaid expense (asset → expense) or deferred
|
||||
revenue (liability → revenue) evenly over the months it covers, posting on
|
||||
each month end, to the cent (the last month absorbs rounding). Like
|
||||
revenue (liability → revenue) over the time it covers, posting on each
|
||||
month end, to the minor unit (rules.md B-16). A new schedule's convention
|
||||
is `books.schedule_convention` (unset: `daily`, D-11/D-37): `daily`
|
||||
spreads by the day from `start`, the first day covered, as depreciation
|
||||
does, so a mid-month start posts a small first and last month (a 12-month
|
||||
policy from March 15 is thirteen month ends: 54.84, eleven of 100.00,
|
||||
45.16 for 1,200.00); `monthly_even` is equal months, the last absorbing
|
||||
rounding, and starts on the 1st (D-37: revenue is never earned early,
|
||||
ASC 606-10-25-23). Schedules registered before P3 stay `monthly_even`
|
||||
(mid-month starts included), forever. Like
|
||||
depreciation, the schedule is the evidence: its postings are exempt from
|
||||
the receipt policy (the original payment carries the receipt). Recurring
|
||||
entries never post: they generate drafts, so every real receipt still goes
|
||||
through the policy.
|
||||
|
||||
- `books.create_schedule { kind: "prepaid_expense" | "deferred_revenue",
|
||||
description, total, start, months, balance_account, pl_account }` →
|
||||
`{ schedule_id, ..., total, remaining, months: [{ date, amount, posted }],
|
||||
plan_error }`. Prepaid needs an asset and an expense account; deferred
|
||||
revenue a liability and a revenue account. `plan_error` is null, except
|
||||
for a schedule an earlier version accepted whose months run past the
|
||||
last date the books can compute: then it says why, `remaining` is null
|
||||
and `months` empty, and `books.run_schedules` refuses until it is dealt
|
||||
with.
|
||||
description, total, start, months, balance_account, pl_account,
|
||||
convention?: "daily" | "monthly_even" }` → `{ schedule_id, ...,
|
||||
convention, start, end, next, total, remaining, months: [{ date, amount,
|
||||
posted }], plan_error }`. Prepaid needs an asset and an expense account;
|
||||
deferred revenue a liability and a revenue account. `start` is the
|
||||
first day covered (for a monthly_even schedule an earlier version took
|
||||
mid-month, the 1st of its month), `end` the last day covered, `next`
|
||||
the next month end to post (null once every month has). Refused: a
|
||||
convention but those two (naming them); a `monthly_even` schedule not
|
||||
starting on the 1st (naming the 1st, and `daily`); one whose months
|
||||
aren't whole minor units (yen); a daily one whose last day is after
|
||||
9999-12-31. `plan_error` is null, except for a schedule an earlier
|
||||
version accepted whose months run past the last date the books can
|
||||
compute: then it says why, `remaining` and `next` are null and `months`
|
||||
empty, and `books.run_schedules` refuses until it is dealt with.
|
||||
- `books.list_schedules {}` → `{ schedules: [...] }`, same shape.
|
||||
- `books.run_schedules { through?, confirmed? }` → `{ status: "preview" |
|
||||
"posted", postings: [{ schedule_id, date, description, amount,
|
||||
entry_id? }] }`: every unposted month ending on or before `through`
|
||||
(default today). Idempotent; closed periods are refused.
|
||||
entry_id? }], skipped: [{ schedule_id, date, reason }] }`: every
|
||||
unposted month ending on or before `through` (default today).
|
||||
Idempotent; a month reversed within its month is due again; a month
|
||||
dated in a closed period, or whose posting was reversed after the month
|
||||
ended (posting it again would recognize it twice until then), is left
|
||||
and listed under `skipped` with why (book it in an open period by hand,
|
||||
or not at all), never a failed run.
|
||||
- `books.create_recurring { description, frequency: "weekly" | "monthly" |
|
||||
"quarterly" | "yearly", start, end?, lines: [{ account, side, amount }] }`
|
||||
→ `{ recurring_id, ..., generated: [] }`. Lines must balance. Monthly and
|
||||
|
||||
+191
-10
@@ -71,7 +71,12 @@ minor unit), `Ledger::add_line_fx` (base amount = foreign amount × rate,
|
||||
rounded to the minor unit; original kept), `Ledger::post_entry` (every
|
||||
line, whatever made it: payroll, closes, disposals; `FinerThanMinorUnit`,
|
||||
since 2026-10-13 after a payroll run posted $65.27794). Journals from
|
||||
before replay as booked (`replay_post_entry`). **Checked:**
|
||||
before replay as booked (`replay_post_entry`). A new asset's cost and
|
||||
salvage are in the minor unit too (P3: every depreciation and disposal
|
||||
line derives from them; `validate_new_asset`), and depreciation and daily
|
||||
schedules round to it (B-15, B-16); assets an earlier version took finer
|
||||
replay as journaled. **Checked:**
|
||||
`an_asset_cost_finer_than_a_cent_is_refused`,
|
||||
`a_foreign_line_is_booked_in_whole_base_units`,
|
||||
`no_entry_posts_a_fraction_of_the_minor_unit`; the GnuCash oracle.
|
||||
**Checked:** `foreign_currency_line_is_rejected`, `fx_overflow_is_an_error`.
|
||||
@@ -679,9 +684,127 @@ allocation.
|
||||
Tax conventions (half-year, mid-month, mid-quarter): IRS Pub. 946
|
||||
(`sources/irs-depreciation.md`), for the CPA.
|
||||
|
||||
**Enforced:** `assets::depreciation_schedule`. **Checked:**
|
||||
The rule, exactly (P3, defect 22; the engineer settled it from ASC
|
||||
360-10-35-4, 2026-10-20):
|
||||
|
||||
1. The life is `[in-service date, in-service date + life months)` (chrono
|
||||
clamps a day past a month's end: Jan 31 plus one month is Feb 28), cut
|
||||
into the calendar months it touches.
|
||||
2. Through day `k` of a month but the life's last, the depreciation is
|
||||
(cost − salvage) / life × (the months before, each as days held over
|
||||
days in the month, + `k` / days in this month): the monthly rate by
|
||||
days.
|
||||
3. The life's last month takes what the months before it leave, evenly
|
||||
over its days: through its last day the depreciation is exactly cost
|
||||
− salvage, and through an earlier day of it, prorated by days, never
|
||||
plugged to the whole. (Without clamping its daily rate is the first
|
||||
month's: the remainder is that month's missing days.) Before P3 any
|
||||
schedule through a day of the final month, and every disposed asset's,
|
||||
plugged to the whole: a disposal in the final month posted too much
|
||||
depreciation and too large a gain.
|
||||
4. Each cumulative figure is rounded half away from zero to the
|
||||
currency's minor unit (cents; whole yen; a thousandth of a dinar;
|
||||
ISO 4217, `sources/iso-4217.md`), in exact integer arithmetic
|
||||
(`kernel::prorated_through`); a month's amount is the difference of
|
||||
consecutive ones. Through every month end this is the per-month
|
||||
running total books posted before P3, except where the exact figure is
|
||||
a half-cent tie, which the old 28-digit Decimal quotients could round
|
||||
either way; P3 rounds it up, exactly, moving that cent to the month
|
||||
before.
|
||||
5. A disposed asset depreciates through its disposal day, held
|
||||
(inclusive): its schedule, accumulated depreciation and book value
|
||||
stop there, and equal what the disposal took. The disposal's book
|
||||
value is cost less the schedule through that day whatever the runs
|
||||
posted: depreciation still due posts as the final depreciation;
|
||||
depreciation the disposal month's run (dated its end) posted past the
|
||||
day is credited back to depreciation expense in the disposal entry
|
||||
(`excess_depreciation`); and the disposal clears what the runs carry,
|
||||
so the asset leaves the accumulated depreciation account at zero. A
|
||||
disposal is refused unless every run carries at the end of the
|
||||
disposal's month what it carries now (the disposal clears what they
|
||||
carry now): while a run of a later month stands (reverse it first:
|
||||
credited back at the disposal day, it would leave the disposal month's
|
||||
accumulated depreciation a debit and its expense short), or while a
|
||||
reversal (or re-instatement) of an earlier run dated after the month
|
||||
isn't undone (the disposal would charge that run again, and the
|
||||
reversal take it away only later; undone on any open day, so a closed
|
||||
month never blocks a disposal for ever). A change a run carries only
|
||||
at later month ends (a later month's run reversed after its month) is
|
||||
that run's error and its correction, dated when made, and the disposal
|
||||
leaves it as it is.
|
||||
6. A month's run posts what the schedule says is due through the
|
||||
month's end, less what the books carry then, but never so much that
|
||||
the books would carry more than the schedule at a later month end
|
||||
(`kernel::catch_up`). What the books carry is read entry by entry, each
|
||||
in its own month (`assets::depreciation_effects`): a run adds its
|
||||
amount in its month, a reversal of it takes it away in the reversal's
|
||||
month. So a month skipped, or a run reversed, is caught up in the month
|
||||
the run is made (plan/served.md P3, settled in design, 2026-10-20: an
|
||||
immaterial miss; a material one is restated under ASC 250, a
|
||||
correction for a human and the CPA), and a month whose run was
|
||||
reversed after it ended is never run twice (it carried the month at
|
||||
its end; the run of the reversal's month catches it up). A month a
|
||||
later run caught up, or that rounds to nothing, posts nothing (an entry
|
||||
carries no zero line); a run after the life posts what is still due.
|
||||
A reversed run is un-posted (its month due again, when reversed within
|
||||
it); re-instating one (reversing its reversal) is refused when it would
|
||||
carry the books past the schedule (a later run caught its month up:
|
||||
`assets::catch_up_room`); a disposal, or a run its disposal took as
|
||||
final, can't be reversed; and a run, or a reversal of one, can't be
|
||||
reversed on a day before its own (a correction comes after what it
|
||||
corrects, B-9: a run of March reversed on Jan 31 would leave January
|
||||
and February with negative depreciation; commands only, journals
|
||||
replay as written). The viewer and the store read every run the
|
||||
journal holds, not a month's latest (a run re-instated after its month
|
||||
was run again and that run reversed is the month's run).
|
||||
|
||||
**Enforced:** `assets::depreciation_schedule`, `assets::run_amount`,
|
||||
`assets::catch_up_room`, `assets::depreciation_effects`,
|
||||
`kernel::prorated_through`, `kernel::catch_up`, `kernel::true_up`,
|
||||
`calendar::month_parts`, `Store::run_depreciation`,
|
||||
`Store::dispose_preview` (`check_nothing_after_disposal`),
|
||||
`Store::check_reverse_entry`. **Checked:**
|
||||
`schedule_sums_exactly_to_depreciable_amount`,
|
||||
`salvage_value_is_never_depreciated`, `prop_depreciation_schedule_exact`.
|
||||
`salvage_value_is_never_depreciated`, `prop_depreciation_schedule_exact`,
|
||||
`defect22_depreciation_through_mid_final_month_is_prorated_not_plugged`,
|
||||
`defect22_final_month_drift_is_spread_by_days`,
|
||||
`defect22_a_disposed_assets_schedule_stops_at_its_disposal_day`,
|
||||
`defect22_a_disposal_in_the_final_month_posts_depreciation_by_days`,
|
||||
`defect22_a_disposal_inside_a_posted_month_takes_back_the_extra_days`,
|
||||
`a_run_reinstated_after_its_rerun_was_reversed_still_counts`,
|
||||
`the_viewer_counts_a_run_reinstated_after_its_rerun_was_reversed`,
|
||||
`a_reversal_dated_before_the_entry_it_reverses_is_refused`,
|
||||
`a_disposal_is_taken_once_a_late_change_is_undone_after_its_month_closed`,
|
||||
`a_disposal_whose_month_has_run_charges_what_is_still_due_in_its_entry`,
|
||||
`every_order_of_runs_reversals_disposals_and_closes_keeps_the_books_right`,
|
||||
`prop_any_sequence_keeps_the_books_right`,
|
||||
`prop_the_viewer_reads_an_asset_as_the_store_does`,
|
||||
`prop_run_amounts_are_each_months_run_amount`,
|
||||
`jpy_depreciation_posts_whole_yen`, `a_skipped_month_is_caught_up_in_the_month_run`,
|
||||
`a_reversed_depreciation_month_can_be_run_again`,
|
||||
`a_run_reversed_after_its_month_is_caught_up_in_the_reversals_month`,
|
||||
`reinstating_a_run_a_later_run_caught_up_is_refused`,
|
||||
`a_disposal_before_a_later_months_run_is_refused_until_it_is_reversed`,
|
||||
`a_disposal_before_a_later_reversal_of_an_earlier_run_is_refused`,
|
||||
`a_disposal_entry_cannot_be_reversed`,
|
||||
`prop_the_final_month_takes_the_remainder_by_its_days`,
|
||||
`depreciation_effects_read_each_entry_in_its_month`,
|
||||
`prop_depreciation_is_monotone_and_bounded`,
|
||||
`prop_disposed_schedule_equals_what_disposal_computed`,
|
||||
`prop_month_end_schedules_match_the_legacy_arithmetic_except_half_cent_ties`;
|
||||
`the_charge_never_falls_and_is_continuous_over_real_calendars`; Kani
|
||||
`prorate_parts_sum_to_total`, `prorate_last_part_absorbs_the_remainder`,
|
||||
`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`,
|
||||
`prorated_through_refuses_malformed_input`,
|
||||
`catch_up_never_overtakes_the_schedule`,
|
||||
`catch_ups_is_every_suffixs_catch_up`, `true_up_reaches_the_target`;
|
||||
Lean `Prorate.kernel_parts_total`, `Prorate.kernel_parts_nonneg`,
|
||||
`Prorate.part_near`, `Prorate.cum_near`, `Prorate.chargeMid_eq_cum`,
|
||||
`Prorate.chargeLast_eq_cum`, `Prorate.chargeLast_start`,
|
||||
`Prorate.chargeLast_mono`, `Prorate.cum_mono` (`docs/verification.md`).
|
||||
|
||||
## B-16 Prepaid expenses and deferred revenue are recognized over the period benefited or earned
|
||||
|
||||
@@ -697,8 +820,51 @@ current within the operating cycle; the ASC prescribes no expensing
|
||||
method, so monthly expensing stays a convention. Tax: the 12-month rule, Treas. Reg. §1.263(a)-4(f)
|
||||
(`sources/irs-capitalization.md`).
|
||||
|
||||
**Enforced:** `schedules`. **Checked:** `a_prepaid_expense_is_expensed_month_by_month`,
|
||||
`deferred_revenue_is_earned_to_the_cent`.
|
||||
Conventions (P3, defect 9): `daily` or `monthly_even`. A new schedule
|
||||
spreads by the day from its start (the first day covered), as
|
||||
depreciation does (B-15 rules 1-4: full months equal, the first and last
|
||||
partial months by their days, the last taking what the others leave,
|
||||
rounded to the minor unit): a 12-month policy from March 15 posts
|
||||
thirteen month ends, a small March and a small final March (DECISION,
|
||||
the owner, D-11 confirmed in D-37, 2026-10-20). Schedules registered before
|
||||
P3 keep equal months, the last absorbing rounding, forever: a posted
|
||||
month's replay re-derives its plan. `books.schedule_convention` sets the
|
||||
convention new schedules take (unset: daily). An equal-months schedule
|
||||
starts on the 1st of a month, for prepaid expenses and deferred revenue
|
||||
alike (DECISION, the owner, D-37, 2026-10-20): revenue is never recognized
|
||||
before it is earned (ASC 606-10-25-23), and one rule for both is simpler
|
||||
and never early; schedules an earlier version took mid-month replay as
|
||||
they were. A reversed month is un-posted, so it is due again; a month in
|
||||
a closed period, or one whose posting was reversed after the month ended
|
||||
(posting it again on its own date would recognize it twice until the
|
||||
reversal; a schedule posts its plan's months, so it can't catch one up
|
||||
later), is left by a run for a human, listed with why, never a failed run.
|
||||
A month's posting can't be reversed on a day before its own (a
|
||||
correction comes after what it corrects, B-9; commands only). Not yet
|
||||
vendored: ASC 606-10-25-31 (progress toward satisfying an obligation over
|
||||
time), 606-10-25-32 (one method, applied consistently), 606-10-55-20 (time
|
||||
elapsed as an input method): to read from the owner's ASC login before
|
||||
B-16 cites them (P3 depreciation review R7).
|
||||
|
||||
**Enforced:** `schedules` (`daily_plan`, `amortization_plan`,
|
||||
`validate_schedule`), `kernel::prorate_by_days`, `Ledger::reverse_entry`,
|
||||
`Ledger::schedule_month_hold`.
|
||||
**Checked:** `a_prepaid_expense_is_expensed_month_by_month`,
|
||||
`deferred_revenue_is_earned_to_the_cent`,
|
||||
`defect9_a_new_schedule_spreads_by_the_day`,
|
||||
`defect9_deferred_revenue_daily_is_earned_to_the_cent`,
|
||||
`defect9_legacy_schedules_stay_monthly_even`,
|
||||
`an_equal_months_schedule_starts_on_the_first`,
|
||||
`a_jpy_daily_schedule_posts_whole_yen`, `a_kwd_daily_schedule_rounds_to_the_fils`,
|
||||
`a_reversed_schedule_month_is_due_again`,
|
||||
`a_reversal_dated_before_the_entry_it_reverses_is_refused`,
|
||||
`a_schedule_month_reversed_after_it_ended_is_left_for_a_human`,
|
||||
`a_month_that_rounds_to_nothing_is_never_a_posting`,
|
||||
`a_schedule_month_posted_again_declares_month_reposting_and_replays`,
|
||||
`daily_plans_pin_month_end_clamping`,
|
||||
`prop_daily_plan_sums_exactly_and_lands_on_month_ends`,
|
||||
`prop_monthly_even_plan_is_unchanged`; `ledger-mcp`
|
||||
`mcp_daily_schedule_over_stdio`.
|
||||
|
||||
## B-17 Platform payouts are recorded gross when the business is the principal
|
||||
|
||||
@@ -1258,13 +1424,23 @@ count's first close, `"rounding": "cumulative_since"`), `account_renamed`
|
||||
(before the first `settings.set` naming `policy.require_tax_category`,
|
||||
B-32), `party_creditors` (`party.creditor_set`: defect 24, B-35),
|
||||
`payment_instruments` (`instrument.added`, `entry.instrument_set`:
|
||||
defect 12, B-19) and `vendor_1099_decisions` (`vendor.1099_set` with who
|
||||
defect 12, B-19), `vendor_1099_decisions` (`vendor.1099_set` with who
|
||||
decided, a `not_reportable` decision, its reason or the date it takes
|
||||
effect, and `vendor.w9_flag_set`: defects 18 and 23, D-40, B-19). A value
|
||||
effect, and `vendor.w9_flag_set`: defects 18 and 23, D-40, B-19),
|
||||
`schedule_convention` (a daily schedule's `schedule.registered` carries
|
||||
`convention: daily`; an older version would read it as equal months,
|
||||
B-16) and `month_reposting` (a schedule month or a depreciation period
|
||||
posted again after a reversal un-posted it; an older version would refuse
|
||||
the second `schedule.posted`, and its projection ignore the second
|
||||
`asset.depreciation_posted`, B-15). A value
|
||||
in a line of a known type that only a newer version writes (a 1099
|
||||
decision or reason kind, a payment method's kind) is newer, never
|
||||
corrupt. A later piece asks the owner's go-ahead before the first such
|
||||
declaration (plan D-39).
|
||||
corrupt. A command decides whether it needs a feature, and declares it,
|
||||
before its first journaled line, so a feature refused (a format upgrade
|
||||
awaiting the owner's consent) journals nothing, never an entry without
|
||||
the line that says what it is
|
||||
(`a_refused_journal_feature_journals_nothing`). A later piece asks the
|
||||
owner's go-ahead before the first such declaration (plan D-39).
|
||||
|
||||
**Enforced:** `ledger_store::format` (`JournalFormat::admit`, `declared`,
|
||||
`gate`, `settle`, `declaration`), `journal::ENVELOPE_KEYS`,
|
||||
@@ -1299,7 +1475,12 @@ bounds are the commands', never replay's)
|
||||
0001-01-01 through 9999-12-31 (`ledger_core::calendar`): a date given
|
||||
to a tool is exactly YYYY-MM-DD, a real day in that range (no sign, no
|
||||
space, no fifth year digit); a period is a month of it; a new schedule's
|
||||
last month, a new asset's last day of depreciation, a new recurring
|
||||
last month (a daily one's last day), a new asset's last day of
|
||||
depreciation (so a life ending on 9999-12-31 is taken, and one a day
|
||||
later refused: P3 settled the plan's defect 22 question this way, lives
|
||||
past 9999 refused at registration, the bound the life's last day held;
|
||||
ones an earlier version took open, and their months after 9999-12 show
|
||||
as scheduled and never post), a new recurring
|
||||
entry's start and end, a new period, a new invoice's or bill's date and
|
||||
due date, a disposal's date stay in it. Every amount a command takes,
|
||||
not only a posted line's, is at most a journal line's
|
||||
|
||||
@@ -1368,6 +1368,88 @@ unknown-code check, the accounts passed) and `update_party`'s feature
|
||||
declaration; the bookspec loader's party, payee, decision and payment
|
||||
method setup.
|
||||
|
||||
## P3: depreciation and schedules (defects 22 and 9, 2026-10-20)
|
||||
|
||||
Every expected figure below was worked by hand from the rule (rules.md
|
||||
B-15, B-16) and is written out in the test's doc comment; the fixtures are
|
||||
made up (asset X: 12,000.00 over 12 months from 2026-01-01; a 1,200.00
|
||||
policy from March 15). Each regression that is an accounting rule is also
|
||||
a bookspec example (`bookspec/examples/`, P13, D-34).
|
||||
|
||||
| Law | Test |
|
||||
|---|---|
|
||||
| Through a day of the life's final month, depreciation is prorated by days, never plugged (repro A: December 483.87, accumulated 11,483.87) | `ledger-core` `spec_assets_payroll.rs` `defect22_depreciation_through_mid_final_month_is_prorated_not_plugged`; `spec_asset_tags.rs` `register_as_of_a_day_before_a_same_month_disposal_is_prorated` |
|
||||
| A mid-month life's drift is spread over the final month's days (repro D: through Apr 13, 920.00; Apr 14, 930.00) | `defect22_final_month_drift_is_spread_by_days`; `spec_replayed_extremes.rs` `depreciation_reaches_chronos_last_month` (1,185.92 through Dec 10 of year 262142) |
|
||||
| A disposed asset's schedule stops at its disposal day, held, and equals what the disposal took (repro B) | `defect22_a_disposed_assets_schedule_stops_at_its_disposal_day`, property `prop_disposed_schedule_equals_what_disposal_computed`; `ledger-mcp` `spec_assets.rs` `a_disposed_asset_lists_what_it_depreciated_through_its_disposal` |
|
||||
| A disposal in the final month posts depreciation by days (repro C: 322.58, gain 322.58) | `ledger-store` `tests/assets.rs` `defect22_a_disposal_in_the_final_month_posts_depreciation_by_days` |
|
||||
| A disposal inside a month already run takes back the days after it (R2: excess 500.00, book value 6,500.00) | `defect22_a_disposal_inside_a_posted_month_takes_back_the_extra_days` (store and `ledger-mcp` `spec_assets.rs`) |
|
||||
| A month skipped is caught up in the month the run is made; a later run for it posts nothing (settled in design, P3) | core `a_skipped_month_is_caught_up_in_the_month_run`, store `a_skipped_month_is_caught_up_in_the_month_run`, MCP `a_skipped_month_is_caught_up_and_said` |
|
||||
| A reversed run is un-posted and runs again (feature month_reposting); re-instating a run whose month ran again is refused; a disposal, and a run its disposal took as final, can't be reversed; a reversed run before a disposal leaves nothing behind (R3) | store `a_reversed_depreciation_month_can_be_run_again`, `a_disposal_entry_cannot_be_reversed`, `a_reversed_run_before_a_disposal_leaves_nothing_behind`; core `a_reversed_schedule_month_is_due_again` |
|
||||
| What the books carry is read entry by entry, each in its own month: a run reversed after its month ended isn't run twice (its month posts nothing; the reversal's month catches it up), for depreciation; a schedule month reversed after it ended is left for a human, listed with why (P3 review) | store `a_run_reversed_after_its_month_is_caught_up_in_the_reversals_month`; GUI `a_run_reversed_after_its_month_leaves_the_reversals_month_due`; core `schedules.rs` `a_schedule_month_reversed_after_it_ended_is_left_for_a_human`; store `format.rs` `a_schedule_month_posted_again_declares_month_reposting_and_replays` |
|
||||
| Re-instating a run (reversing its reversal) never carries the books past the schedule, nor past cost less salvage (P3 review) | store `reinstating_a_run_a_later_run_caught_up_is_refused` |
|
||||
| What the books carry is each run and each reversal in its own month, alternately adding and taking away (P3 review) | core `spec_assets_payroll.rs` `depreciation_effects_read_each_entry_in_its_month` |
|
||||
| A schedule month that rounds to nothing is never a posting: left out of a daily plan, never due in an equal-months one (P3 review; no test held it before) | core `schedules.rs` `a_month_that_rounds_to_nothing_is_never_a_posting` |
|
||||
| A disposal is refused while a later month's run stands, or while an earlier run's reversal is dated after the disposal's month; once undone, the disposal month ends at 0.00 accumulated and its expense whole (P3 review) | store `a_disposal_before_a_later_months_run_is_refused_until_it_is_reversed`, `a_disposal_before_a_later_reversal_of_an_earlier_run_is_refused` |
|
||||
| A disposed asset's figures in the viewer are what its disposal took (its excess or catch-up from the disposal entry) and none of its months is Due (P3 review) | GUI `a_disposed_assets_figures_are_what_its_disposal_took` |
|
||||
| The final month is C + (D − C) × k / d, rounded half up, against arithmetic written in the test (P3 review) | property `prop_the_final_month_takes_the_remainder_by_its_days` (500 cases) |
|
||||
| A month that rounds to nothing posts and journals nothing (R10) | store `run_depreciation_posts_nothing_for_a_month_that_rounds_to_nothing`, MCP `run_depreciation_skips_a_month_that_rounds_to_nothing` |
|
||||
| Depreciation and daily schedules round to the currency's minor unit (R1: yen, dinars) | `jpy_depreciation_posts_whole_yen`, store `jpy_depreciation_runs_post_whole_yen`, `schedules.rs` `a_jpy_daily_schedule_posts_whole_yen`, `a_kwd_daily_schedule_rounds_to_the_fils`; `money.rs` `steps_are_the_currencys_minor_unit` |
|
||||
| A new schedule spreads by the day (1,200.00 from March 15: 54.84, eleven 100.00, 45.16); deferred revenue is earned by the day (290.32, 333.34, 333.33, 43.01) | `schedules.rs` `defect9_a_new_schedule_spreads_by_the_day`, `defect9_deferred_revenue_daily_is_earned_to_the_cent`; `schedules.rs` unit `a_daily_plan_spreads_by_the_day`; `ledger-mcp` `mcp_stdio.rs` `mcp_daily_schedule_over_stdio` |
|
||||
| Schedules from before P3 stay equal months, forever | `defect9_legacy_schedules_stay_monthly_even`, property `prop_monthly_even_plan_is_unchanged`; every old-books fixture (`books_an_earlier_version_wrote_open_to_its_figures`) |
|
||||
| An equal-months schedule starts on the 1st (D-37); a daily one's last day is in the calendar | `an_equal_months_schedule_starts_on_the_first`, `spec_bounds.rs` `a_daily_schedule_whose_last_day_passes_9999_is_refused` |
|
||||
| chrono's month-end clamping is pinned for daily plans (Jan 29, 30, 31; Feb 29) | `schedules.rs` unit `daily_plans_pin_month_end_clamping` |
|
||||
| A new asset's cost and salvage are in the minor unit (replay takes finer ones, computed); a life ending 9999-12-31 is taken, a day later refused | `spec_bounds.rs` `an_asset_cost_finer_than_a_cent_is_refused`, `a_life_ending_on_9999_12_31_is_accepted`, `a_life_ending_after_it_is_refused` |
|
||||
| The first daily schedule declares feature schedule_convention; an older reader stops there; monthly_even declares nothing; an unknown convention is newer, never corrupt | `ledger-store` `tests/format.rs` `the_first_daily_schedule_declares_its_feature_and_an_older_reader_stops_there`, `a_monthly_even_schedule_declares_no_feature`, `an_unknown_schedule_convention_is_newer_not_corrupt`; `format.rs` unit `an_older_reader_stops_at_p3s_features` |
|
||||
| Accumulated depreciation never falls, never exceeds cost − salvage, is 0 before service and the whole from the last day (sampled days: the first and final months, two between, a dozen month ends; R9) | property `prop_depreciation_is_monotone_and_bounded` (500 cases) |
|
||||
| No posted month moves but a half-cent tie's cent (old Decimal arithmetic as the oracle) | property `prop_month_end_schedules_match_the_legacy_arithmetic_except_half_cent_ties` (500 cases) |
|
||||
| A daily plan sums exactly, lands on month ends, each amount > 0; from the 1st, `months` postings within a cent of total/months | property `prop_daily_plan_sums_exactly_and_lands_on_month_ends` (2,000 cases) |
|
||||
| Refused commands never reach the journal, now with daily and monthly_even schedules and zero-month runs | `ledger-store` `tests/store.rs` `refused_commands_never_reach_the_journal` |
|
||||
| Every order of runs, reversals (any day, before an entry's own included), re-runs, re-instatements, disposals and closes on one asset keeps the books right: refusals journal nothing; at every month end before the disposal the expense is the accumulated depreciation and within the schedule; one run in effect per month; a disposal's month and the end at the schedule through its day, accumulated 0.00, the gain proceeds less book value; an active asset run month by month past its life takes exactly cost less salvage (P3 review round 2) | store `asset_orders.rs` `every_order_of_runs_reversals_disposals_and_closes_keeps_the_books_right` (exhaustive, three commands deep; four and five with `WB_ORDERS_DEPTH`, run clean 2026-10-21), `prop_any_sequence_keeps_the_books_right` (64 cases, up to 9 commands, any day) |
|
||||
| The viewer reads an asset as the store does, whatever the runs, reversals and disposals: what the books carry, each finished month's status against a run of it, what is unposted (P3 review round 2) | GUI `prop_the_viewer_reads_an_asset_as_the_store_does` (48 cases), `the_viewer_counts_a_run_reinstated_after_its_rerun_was_reversed` |
|
||||
| A run re-instated after its month was run again and that run reversed still counts: in the store, the disposal and the viewer (P3 review round 2; before, the projection's row named the reversed re-run) | store `a_run_reinstated_after_its_rerun_was_reversed_still_counts`; GUI `the_viewer_counts_a_run_reinstated_after_its_rerun_was_reversed` |
|
||||
| A depreciation run's or schedule month's reversal can't be dated before the entry it reverses (P3 review round 2: a run of March reversed on Jan 31, then a disposal on Feb 15, ended February with a 1,000.00 debit in accumulated depreciation) | store `a_reversal_dated_before_the_entry_it_reverses_is_refused` |
|
||||
| A disposal waits only until what the runs carry at its month's end is what they carry now, so a late change undone after its month closed lets it through (P3 review round 2; round 1 refused it for ever) | store `a_disposal_is_taken_once_a_late_change_is_undone_after_its_month_closed` |
|
||||
| Depreciation still due when the disposal's month has a run is charged in the disposal entry, never as a second run of the month (P3 review round 2; no store test held it) | store `a_disposal_whose_month_has_run_charges_what_is_still_due_in_its_entry` |
|
||||
| A command that needs a journal feature decides and declares it before its first journaled line: a refused feature journals nothing (the format-upgrade gate) | store `format.rs` `a_refused_journal_feature_journals_nothing` |
|
||||
| What a viewer's one pass says a run of each month posts is run_amount's for that month | property `prop_run_amounts_are_each_months_run_amount` (500 cases); Kani `catch_ups_is_every_suffixs_catch_up` |
|
||||
| The kernel on i64 and i32 (Kani's) is the kernel on i128 and Decimal (the engine's) | `kernel_bridge.rs` `prorate_agrees_at_i64_and_i128`, `prorated_through_agrees_at_i32_and_i128`, `catch_up_and_true_up_agree` |
|
||||
| The charge never falls and is continuous at month ends, over every 1-to-3-month span from every day of a common and a leap year | `kernel.rs` unit `the_charge_never_falls_and_is_continuous_over_real_calendars` (exhaustive) |
|
||||
| Proofs | Kani `prorate_parts_sum_to_total`, `prorate_last_part_absorbs_the_remainder`, `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`, `prorated_through_refuses_malformed_input`, `catch_up_never_overtakes_the_schedule`, `catch_ups_is_every_suffixs_catch_up`, `true_up_reaches_the_target`; Lean `Prorate.lean` (`cum_mono`, `cum_le`, `cum_full`, `parts_telescope`, `prorate_total`, `prorate_nonneg`, `part_within_a_unit`, `roundUnit_near`, `roundUnit_scale`, `cum_near`, `part_near`, `chargeMid_eq_cum`, `chargeLast_eq_cum`, `chargeLast_start`, `chargeLast_mono`, `kernel_parts_total`, `kernel_parts_nonneg`; `docs/verification.md`) |
|
||||
|
||||
**Mutation targets for P12** (none run in P3, D-33): `kernel.rs`
|
||||
`prorated_through` (the last/not-last branch, the rounding comparison
|
||||
`r >= step - r` against `>`, the `k == days && last` shortcut, the cap at
|
||||
the total, every refusal), `round_to_unit`, `prorate_by_days`,
|
||||
`part_weight`, `MonthPart::is_valid`, the three `Units` impls, `catch_up`,
|
||||
`catch_ups`, `true_up`; `calendar.rs` `month_parts`, `days_in_month`,
|
||||
`next_month_start`; `money.rs` `Steps::of`, `Steps::decimal`; `assets.rs`
|
||||
`schedule_months` (the stop date's `min` with the disposal day, the
|
||||
inclusive `+ 1`, the partial-month break), `life_end`, `check_disposal`,
|
||||
`run_amount`, `catch_up_room`, `depreciation_effects` (the chain's
|
||||
alternating signs), `run_amounts` (its stop past the life), `cumulatives`;
|
||||
`ledger.rs` `check_new_asset` (the minor-unit checks, the `pred()`
|
||||
bound), `check_reversal` SCENARIO 5, `in_effect`, `reverse_entry_steps`'
|
||||
schedule un-posting, `schedule_month_posted_before`,
|
||||
`schedule_month_reversed_after`, `schedule_month_hold`; `schedules.rs`
|
||||
`daily_plan`, `plan_for`, `validate_schedule` (the 1st rule, the daily
|
||||
bound, the minor-unit plan check), `first_day`, `last_day`, `next`, `due`'s
|
||||
zero months, `ScheduleConvention::parse`/`key`; `settings.rs` the new key
|
||||
and `schedule_convention()`'s default; `store.rs` `run_depreciation`
|
||||
(nothing due, the feature on a re-run), `live_runs`,
|
||||
`depreciation_runs_ever`, `dispose_preview` (true-up split, `in_month`),
|
||||
`check_nothing_after_disposal`, `disposal_lines`, `check_reverse_entry`
|
||||
(the re-instatement room),
|
||||
`register_schedule` (the feature only for daily), `ensure_feature`,
|
||||
`post_schedule_month`'s feature, `run_schedules`' held months, the
|
||||
`schedule.registered` replay arm's convention, the `asset.depreciation_posted`
|
||||
projection upsert, `period_month`; `format.rs` `KNOWN_FEATURES`,
|
||||
`declared_knowing`; `server.rs` `create_schedule`'s default resolution,
|
||||
`schedule_out`'s new fields, `run_depreciation`'s skips, `list_assets`'
|
||||
`schedule_error`, `dispose_asset`'s debits, `run_schedules`' held months;
|
||||
`ledger-gui` `books.rs` `disposal_true_up`, `pages/assets.rs` `unposted`
|
||||
and `schedule`'s disposed months. Equivalent mutants foreseen:
|
||||
none (`r >= step - r` against `>` is killed by `half_a_unit_rounds_up`).
|
||||
|
||||
## Benchmarks (baseline 2026-10-13)
|
||||
|
||||
No budget, by decision (the owner, 2026-10-13: the books do little work
|
||||
|
||||
+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