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:
swaitsandClaude Opus 5.5 committed 2026-10-21 14:04:11 -06:00
1 parent 1304b9c0ad
commit b0a9fd8ad4
69 files changed
+10529 -841

No files matched your search

+414 -3
View File
@@ -300,7 +300,7 @@ proptest! {
}).unwrap();
let asset = ledger.get_asset(id).unwrap();
let far = asset.purchase_date.checked_add_months(chrono::Months::new(life + 24)).unwrap();
let sched = depreciation_schedule(asset, far);
let sched = depreciation_schedule(asset, far).unwrap();
// The lifetime total is exactly cost minus salvage: never a penny off.
let total: Decimal = sched.iter().map(|l| l.amount).sum();
prop_assert_eq!(total, cost - salvage);
@@ -314,8 +314,8 @@ proptest! {
}
// Book value at any date matches the schedule.
if let Some(last) = sched.last() {
prop_assert_eq!(book_value(asset, far), last.book_value);
prop_assert_eq!(accumulated_depreciation(asset, far), last.accumulated);
prop_assert_eq!(book_value(asset, far).unwrap(), last.book_value);
prop_assert_eq!(accumulated_depreciation(asset, far).unwrap(), last.accumulated);
}
}
}
@@ -471,3 +471,414 @@ proptest! {
prop_assert_eq!(p.calendar_year(), today.year());
}
}
// ---------------------------------------------------------------------------
// P3: depreciation by the day through the final month (defect 22), and
// schedules spread by day (defect 9). Each law is checked against arithmetic
// written here, never read back from the code.
// ---------------------------------------------------------------------------
use chrono::Datelike;
use ledger_core::schedules::{amortization_plan, daily_plan, month_end};
use ledger_core::{AssetStatus, disposal_amounts};
mod p3 {
use super::*;
use chrono::Months;
/// A USD asset on a ledger, as a command registers it.
pub fn asset_on(
cost_cents: u64,
salvage_cents: u64,
life: u32,
start: NaiveDate,
) -> (Ledger, ledger_core::AssetId) {
let mut ledger = Ledger::new(Currency::USD);
let ca = ledger
.add_account("1500", "Equipment", AccountType::Asset, None, false)
.unwrap();
let aa = ledger
.add_account("1501", "Accum", AccountType::Asset, None, false)
.unwrap();
let ex = ledger
.add_account("6100", "Depr", AccountType::Expense, None, false)
.unwrap();
let id = ledger
.register_asset(NewAsset {
description: "prop".to_string(),
purchase_date: start,
cost: Money::new(Decimal::new(cost_cents as i64, 2), Currency::USD),
salvage_value: Money::new(Decimal::new(salvage_cents as i64, 2), Currency::USD),
useful_life_months: life,
cost_account: ca,
accum_depr_account: aa,
expense_account: ex,
})
.unwrap();
(ledger, id)
}
pub fn arb_day() -> impl Strategy<Value = NaiveDate> {
(2000i32..=2100, 1u32..=12, 1u32..=31).prop_map(|(y, m, d)| {
(1..=d)
.rev()
.find_map(|d| NaiveDate::from_ymd_opt(y, m, d))
.unwrap()
})
}
/// The days in `d`'s month.
pub fn days_in(d: NaiveDate) -> u32 {
month_end(d).day()
}
/// The last day of `d`'s month.
pub fn month_end_of(d: NaiveDate) -> NaiveDate {
month_end(d)
}
/// Before P3: the per-month running total in Decimal, rounded half away
/// from zero to the cent, at each month end of the life (the final
/// month's plug left out: it only ever set the life's end to cost −
/// salvage, as P3 does). A copy of assets.rs as it was, the oracle for
/// "no posted month moves".
pub fn legacy_month_ends(
depreciable: Decimal,
life: u32,
start: NaiveDate,
) -> Vec<(NaiveDate, Decimal)> {
let end = start.checked_add_months(Months::new(life)).unwrap();
let rate = depreciable / Decimal::from(life);
let mut out = Vec::new();
let mut exact = Decimal::ZERO;
let mut month = start.with_day(1).unwrap();
while month < end {
let next = month.checked_add_months(Months::new(1)).unwrap();
let held = (next.min(end) - start.max(month)).num_days();
exact += rate * Decimal::from(held) / Decimal::from(days_in(month));
out.push((
month_end(month),
exact.round_dp_with_strategy(
2,
rust_decimal::RoundingStrategy::MidpointAwayFromZero,
),
));
month = next;
}
out
}
/// The exact cumulative charge through the end of the life's `i`th
/// month (not the last), in cents, as a fraction: (numerator,
/// denominator) of D × (months held, each days held over days in the
/// month) / life, in whole MONTH_L-ths.
pub fn exact_through(
depreciable_cents: i128,
life: u32,
start: NaiveDate,
through: NaiveDate,
) -> (i128, i128) {
const L: i128 = 377_580;
let mut elapsed = 0i128;
let mut month = start.with_day(1).unwrap();
while month <= through {
let next = month.checked_add_months(Months::new(1)).unwrap();
let from = start.max(month);
let to = next.min(through.succ_opt().unwrap());
elapsed += i128::from((to - from).num_days() as i32) * (L / i128::from(days_in(month)));
month = next;
}
(depreciable_cents * elapsed, i128::from(life) * L)
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(500))]
/// B-15 INVARIANT 5 (defect 22): accumulated depreciation never falls,
/// never exceeds cost − salvage, is 0 before the in-service date and
/// cost − salvage from the life's last day on; each schedule's lines
/// sum to its accumulated and none is negative. Sampled through: every
/// day of the first and final months and of two months between, a
/// dozen month ends, and the days around the life (P3 depreciation
/// review R9: stepping every day of 600-month lives is P12's).
#[test]
fn prop_depreciation_is_monotone_and_bounded(
cost_cents in 2u64..=100_000_000_000,
salvage_frac in 0u64..1000,
life in 1u32..=600,
start in p3::arb_day(),
picks in prop::collection::vec(0u32..600, 14),
) {
let salvage_cents = (cost_cents - 1) * salvage_frac / 1000;
let (ledger, id) = p3::asset_on(cost_cents, salvage_cents, life, start);
let a = ledger.get_asset(id).unwrap();
let dep = Decimal::new((cost_cents - salvage_cents) as i64, 2);
let end = start.checked_add_months(chrono::Months::new(life)).unwrap();
let last_day = end.pred_opt().unwrap();
let month_of = |k: u32| start.with_day(1).unwrap().checked_add_months(chrono::Months::new(k)).unwrap();
let mut days: Vec<NaiveDate> = vec![
start.pred_opt().unwrap(), start, last_day, end, end + chrono::Days::new(400),
];
let months = life;
for k in [0, months, picks[0] % (months + 1), picks[1] % (months + 1)] {
let m = month_of(k);
days.extend((0..p3::days_in(m)).map(|i| m + chrono::Days::new(u64::from(i))));
}
days.extend(picks[2..].iter().map(|&k| p3::month_end_of(month_of(k % (months + 1)))));
days.sort();
days.dedup();
let mut prev = Decimal::ZERO;
for day in days {
let s = depreciation_schedule(a, day).unwrap();
let acc = accumulated_depreciation(a, day).unwrap();
prop_assert_eq!(s.iter().map(|l| l.amount).sum::<Decimal>(), acc);
prop_assert!(s.iter().all(|l| l.amount >= Decimal::ZERO));
prop_assert!(acc >= prev, "{}: {} < {}", day, acc, prev);
prop_assert!(acc <= dep);
if day < start {
prop_assert_eq!(acc, Decimal::ZERO);
}
if day >= last_day {
prop_assert_eq!(acc, dep);
}
prev = acc;
}
}
/// Defect 22, reproduction B as a law: a disposed asset's schedule
/// shows exactly what its disposal computed, for any disposal day in or
/// after the life, and is unchanged through any later date.
#[test]
fn prop_disposed_schedule_equals_what_disposal_computed(
cost_cents in 2u64..=100_000_000_000,
salvage_frac in 0u64..1000,
life in 1u32..=600,
start in p3::arb_day(),
after in 0u64..20_000,
later in 0u64..1_000,
) {
let salvage_cents = (cost_cents - 1) * salvage_frac / 1000;
let (mut ledger, id) = p3::asset_on(cost_cents, salvage_cents, life, start);
let on = start + chrono::Days::new(after);
let proceeds = Money::new(Decimal::ONE, Currency::USD);
let want = disposal_amounts(ledger.get_asset(id).unwrap(), on, proceeds).unwrap();
if ledger_core::calendar::check(on).is_err() {
return Ok(());
}
ledger.dispose_asset(id, on, proceeds).unwrap();
let a = ledger.get_asset(id).unwrap();
let disposed = matches!(a.status, AssetStatus::Disposed { .. });
prop_assert!(disposed);
let full = depreciation_schedule(a, NaiveDate::MAX).unwrap();
prop_assert_eq!(full.last().map_or(Decimal::ZERO, |l| l.accumulated), want.accumulated);
prop_assert_eq!(
accumulated_depreciation(a, on + chrono::Days::new(later)).unwrap(),
want.accumulated
);
prop_assert_eq!(book_value(a, on).unwrap(), want.book_value);
}
/// B-15 rule 3 (defect 22), against arithmetic written here: through
/// any day k of the life's final month (its d days), accumulated is C +
/// (D − C) × k / d rounded half up to the cent, C the exact charge of
/// the months before (D × each month's days held over its days, over
/// the life), D cost less salvage; on its last day exactly D. So the
/// final month is prorated by its days, never plugged.
#[test]
fn prop_the_final_month_takes_the_remainder_by_its_days(
cost_cents in 2u64..=100_000_000_000,
salvage_frac in 0u64..1000,
life in 1u32..=600,
start in p3::arb_day(),
) {
let salvage_cents = (cost_cents - 1) * salvage_frac / 1000;
let (ledger, id) = p3::asset_on(cost_cents, salvage_cents, life, start);
let a = ledger.get_asset(id).unwrap();
let dep = i128::from(cost_cents - salvage_cents);
let end = start.checked_add_months(chrono::Months::new(life)).unwrap();
let last_day = end.pred_opt().unwrap();
let final_first = last_day.with_day(1).unwrap().max(start);
let d = i128::from((end - final_first).num_days() as i32);
let (num, den) = p3::exact_through(dep, life, start, final_first.pred_opt().unwrap());
for k in 1..=d {
let through = final_first + chrono::Days::new((k - 1) as u64);
// C + (D − C) k / d = (num d + (D den − num) k) / (den d).
let (x, y) = (num * d + (dep * den - num) * k, den * d);
let want = if k == d {
dep
} else {
x / y + i128::from(2 * (x % y) >= y)
};
prop_assert_eq!(
accumulated_depreciation(a, through).unwrap(),
Decimal::new(want as i64, 2),
"{} of {}", through, last_day
);
}
}
/// B-15 INVARIANT 6 (P3 depreciation brief 2.3, R1, R8): through every
/// month end of the life, the schedule is the pre-P3 running total
/// (USD, a two-place currency), except where the exact cumulative is a
/// half-cent tie, which the old 28-digit Decimal quotients could round
/// either way: there the new figure is the exact one, half a cent up.
/// So no posted month moves, but a tie's cent.
#[test]
fn prop_month_end_schedules_match_the_legacy_arithmetic_except_half_cent_ties(
cost_cents in 2u64..=100_000_000_000,
salvage_frac in 0u64..1000,
life in 1u32..=600,
start in p3::arb_day(),
) {
let salvage_cents = (cost_cents - 1) * salvage_frac / 1000;
let (ledger, id) = p3::asset_on(cost_cents, salvage_cents, life, start);
let a = ledger.get_asset(id).unwrap();
let dep_cents = i128::from(cost_cents - salvage_cents);
let dep = Decimal::new(dep_cents as i64, 2);
let legacy = p3::legacy_month_ends(dep, life, start);
let new = depreciation_schedule(a, NaiveDate::MAX).unwrap();
prop_assert_eq!(legacy.len(), new.len());
let n = new.len();
for (i, ((at, old), line)) in legacy.iter().zip(&new).enumerate() {
if i + 1 == n {
// The final month: cost − salvage, then (the plug) as now.
prop_assert_eq!(line.accumulated, dep);
continue;
}
if line.accumulated != *old {
let (num, den) = p3::exact_through(dep_cents, life, start, *at);
// A half-cent tie: num/den = q + 1/2 exactly.
prop_assert_eq!(2 * (num % den), den, "{}: {} vs {}, not a tie", at, line.accumulated, old);
let up = Decimal::new(((num / den) + 1) as i64, 2);
prop_assert_eq!(line.accumulated, up);
prop_assert_eq!((line.accumulated - *old).abs(), Decimal::new(1, 2));
}
}
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(2000))]
/// daily_plan INVARIANTS 1-3 (defect 9): the plan sums to the total
/// exactly, every amount is > 0, the dates are distinct month ends
/// (ascending) from the start's month through the last day's; from the
/// 1st with at least a cent a month, there are `months` postings, each
/// but the last within a cent of total/months, and the last takes the
/// rest.
#[test]
fn prop_daily_plan_sums_exactly_and_lands_on_month_ends(
total_cents in 1i64..=100_000_000_000,
start in p3::arb_day(),
months in 1u32..=600,
on_the_first in any::<bool>(),
) {
let start = if on_the_first { start.with_day(1).unwrap() } else { start };
let total = Money::new(Decimal::new(total_cents, 2), Currency::USD);
let plan = daily_plan(total, start, months).unwrap();
let last_day = start.checked_add_months(chrono::Months::new(months)).unwrap().pred_opt().unwrap();
prop_assert_eq!(plan.iter().map(|(_, a)| *a).sum::<Decimal>(), total.amount);
prop_assert!(plan.iter().all(|(_, a)| *a > Decimal::ZERO));
prop_assert!(plan.windows(2).all(|w| w[0].0 < w[1].0));
for (d, _) in &plan {
prop_assert_eq!(*d, month_end(*d));
prop_assert!(*d >= month_end(start) && *d <= month_end(last_day));
}
if on_the_first && total_cents >= i64::from(months) {
prop_assert_eq!(plan.len() as u32, months);
let even = total.amount / Decimal::from(months);
for (_, a) in &plan[..plan.len() - 1] {
prop_assert!((*a - even).abs() <= Decimal::new(1, 2), "{} vs {}", a, even);
}
let others: Decimal = plan[..plan.len() - 1].iter().map(|(_, a)| *a).sum();
prop_assert_eq!(plan.last().unwrap().1, total.amount - others);
}
}
/// The equal-months plan is unchanged forever (a posted month's replay
/// re-derives it): a copy of amortization_plan as it was before P3.
#[test]
fn prop_monthly_even_plan_is_unchanged(
total_cents in 1i64..=100_000_000_000,
start in p3::arb_day(),
months in 1u32..=600,
) {
let total = Decimal::new(total_cents, 2);
let each = (total / Decimal::from(months)).round_dp(2);
let first = start.with_day(1).unwrap();
let want: Vec<(NaiveDate, Decimal)> = (0..months)
.map(|i| {
let m = first.checked_add_months(chrono::Months::new(i)).unwrap();
let a = if i + 1 == months { total - each * Decimal::from(months - 1) } else { each };
(month_end(m), a)
})
.collect();
prop_assert_eq!(amortization_plan(total, start, months).unwrap(), want);
}
}
/// The half-cent tie the P3 depreciation review (R8) names: 1,000.03 over
/// 36 months from 2026-01-01. Through month 18 the exact figure is
/// 100,003 × 18/36 = 50,001.5 cents, a tie: P3 rounds it up, 500.02, and
/// month 19 (100,003 × 19/36 = 52,779.36 cents) is 527.79. The old
/// running total in 28-digit Decimal lands a hair under the tie and gives
/// 500.01, so that cent moves from month 19 to month 18 (the change-log
/// case); every other month end is the same, and the life ends at
/// 1,000.03 either way.
#[test]
fn a_half_cent_tie_rounds_up_exactly() {
let start = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let (ledger, id) = p3::asset_on(100_003, 0, 36, start);
let new = depreciation_schedule(ledger.get_asset(id).unwrap(), NaiveDate::MAX).unwrap();
assert_eq!(new[17].accumulated, Decimal::new(50_002, 2));
assert_eq!(new[18].accumulated, Decimal::new(52_779, 2));
assert_eq!(new[35].accumulated, Decimal::new(100_003, 2));
let old = p3::legacy_month_ends(Decimal::new(100_003, 2), 36, start);
let moved: Vec<usize> = (0..35)
.filter(|&i| old[i].1 != new[i].accumulated)
.collect();
assert_eq!(moved, [17], "only the tie moves");
assert_eq!(old[17].1, Decimal::new(50_001, 2));
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(500))]
/// assets::run_amounts INVARIANT 1 (P3 review round 2: it had no test
/// but the viewer's): what it says a run of each month posts, and what
/// the books carry past the schedule then, is run_amount's for that
/// month, over any runs and reversals (each in its own month, a
/// reversal negative), through any month.
#[test]
fn prop_run_amounts_are_each_months_run_amount(
cost_cents in 2u64..=10_000_000,
life in 1u32..=24,
start in p3::arb_day(),
effects in prop::collection::vec((0u32..30, -500_000i64..=500_000), 0..8),
through in 0u32..36,
) {
let (ledger, id) = p3::asset_on(cost_cents, 0, life, start);
let a = ledger.get_asset(id).unwrap();
let first = start.with_day(1).unwrap();
let ym = |i: u32| {
let d = first.checked_add_months(chrono::Months::new(i)).unwrap();
(chrono::Datelike::year(&d), chrono::Datelike::month(&d))
};
let posted: Vec<ledger_core::PostedRun> = effects
.iter()
.map(|&(i, cents)| {
let (year, month) = ym(i);
ledger_core::PostedRun { year, month, amount: Decimal::new(cents, 2) }
})
.collect();
let rows = ledger_core::run_amounts(a, ym(through), &posted).unwrap();
prop_assert!(!rows.is_empty());
prop_assert_eq!((rows[0].0, rows[0].1), ym(0));
for (k, &(y, m, r)) in rows.iter().enumerate() {
prop_assert_eq!((y, m), ym(k as u32), "the months in turn");
let one = ledger_core::run_amount(a, y, m, &posted).unwrap();
prop_assert_eq!(r, one, "{}-{:02}", y, m);
}
}
}