fix(core): due-today is Current; odd cents split by largest remainder
Max-testing pass, step 2: two spec decisions the owner confirmed (2026-10-13), each test-first. Aging (arap::aging_bucket): a document due today was "1-30 days past due"; it is Current, since past due starts the day after (QuickBooks' convention). Spec in the doc comment. Boundary examples at every edge (due today, 1, 30, 31, 60, 61, 90, 91 days) fail on the old code. A property: the balance lands in exactly one bucket, and time only ages it. Member splits (periods::allocate): the last member absorbed every odd cent, so the same partner always got or lost it. Now largest remainder: each share rounded toward zero to the cent, leftover cents to the largest remainders, ties to the earlier member, a loss the negation of the gain. The core is a new kernel function, kernel::apportion, allocation-free so Kani proves it (n <= 3): apportion_is_exact (it succeeds exactly when the shortfall is under a unit per part; then the parts sum to the whole, each gains 0 or 1 unit, one may take the sub-unit fraction) and apportion_is_fair (never a unit to a part while a larger remainder goes without). Kani found an off-by-one in the first version: a shortfall of n units plus a fraction was accepted. Native regression apportion_refuses_a_shortfall_of_a_unit_per_part_or_more. Properties at Decimal (2,000 cases, up to 5 members): conservation, within a cent of each exact share, fairness, sign symmetry; they fail on the old rule. Bridge: apportion agrees on i64 and Decimal. Contract: closes journaled before this change replay exactly. New member closes journal "rounding": "largest_remainder"; journal lines without it replay as CloseTo::MembersLastAbsorbs, the old rule (periods::allocate_last_absorbs, pinned by closes_journaled_under_the_old_rule_replay_with_it). The members' equity statement isn't journaled and uses the new rule. Open gap: Lean proof of apportion's sum law for any n (needs a permutation lemma on the ranking); Kani covers n <= 3, properties n <= 5. The Lean closing theorems already hold for any split summing to the net. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
1 parent
847dd4388d
commit
e0b6d6988f
11 files changed
+502
-36
No files matched your search
@@ -147,6 +147,26 @@ proptest! {
|
||||
prop_assert_eq!(pi.into_iter().map(dec).collect::<Vec<_>>(), pd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apportion_agrees(
|
||||
parts in prop::collection::vec(arb_cents(), 0..5),
|
||||
rems in prop::collection::vec(0i64..100, 5),
|
||||
short in 0i64..500,
|
||||
) {
|
||||
let n = parts.len();
|
||||
let whole: i64 = parts.iter().sum::<i64>() + short;
|
||||
let mut pi = parts.clone();
|
||||
let mut pd: Vec<Decimal> = parts.iter().copied().map(dec).collect();
|
||||
let ri = &rems[..n];
|
||||
let rd: Vec<Decimal> = ri.iter().copied().map(dec).collect();
|
||||
let oi = kernel::apportion(whole, &mut pi, ri, 100);
|
||||
let od = kernel::apportion(dec(whole), &mut pd, &rd, dec(100));
|
||||
prop_assert_eq!(oi.is_some(), od.is_some());
|
||||
if oi.is_some() {
|
||||
prop_assert_eq!(pi.into_iter().map(dec).collect::<Vec<_>>(), pd);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_range_agrees(cents in arb_cents()) {
|
||||
prop_assert_eq!(cents.in_line_range(), dec(cents).in_line_range());
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc 973b72cb286deab3cabcf8a48a797a1aa76e660f1a6f3a7046d02f655ad467a6 # shrinks to cents = 8074248648, shares = [0.0877914951989026063100137174, 0.327846364883401920438957476, 0.2235939643347050754458161866, 0.3607681755829903978052126200]
|
||||
cc 181075cd6abb81d059781749aadb13e990e59e6658279089e173fe655bd6bcf2 # shrinks to cents = 4209937262, shares = [0.3215789473684210526315789474, 0.2426315789473684210526315789, 0.1663157894736842105263157895, 0.2694736842105263157894736842]
|
||||
@@ -319,3 +319,69 @@ proptest! {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Member splits (periods::allocate): conservation, a cent of each exact
|
||||
// share, fairness, and sign symmetry. Rank 2: laws, not recomputation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// 1 to 5 positive shares summing to exactly 1 (the last takes the rest).
|
||||
fn arb_shares() -> impl Strategy<Value = Vec<Decimal>> {
|
||||
prop::collection::vec(1u32..1000, 1..=5).prop_map(|weights| {
|
||||
let total: u32 = weights.iter().sum();
|
||||
let mut shares: Vec<Decimal> = weights
|
||||
.iter()
|
||||
.map(|w| Decimal::from(*w) / Decimal::from(total))
|
||||
.collect();
|
||||
let rest: Decimal = shares[..shares.len() - 1].iter().sum();
|
||||
*shares.last_mut().unwrap() = Decimal::ONE - rest;
|
||||
shares
|
||||
})
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(2000))]
|
||||
|
||||
#[test]
|
||||
fn prop_split_parts_sum_to_the_whole_within_a_cent_of_each_share(
|
||||
cents in -10_000_000_000i64..10_000_000_000, shares in arb_shares(),
|
||||
) {
|
||||
let amount = Decimal::new(cents, 2);
|
||||
let parts = ledger_core::periods::allocate(amount, &shares);
|
||||
prop_assert_eq!(parts.len(), shares.len());
|
||||
prop_assert_eq!(parts.iter().copied().sum::<Decimal>(), amount);
|
||||
for (p, s) in parts.iter().zip(&shares) {
|
||||
prop_assert!((*p - amount * s).abs() < Decimal::new(1, 2), "{p} vs share {s}");
|
||||
prop_assert!(p.scale() <= 2 || p.normalize().scale() <= 2, "{p} is not whole cents");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prop_split_never_rounds_up_a_smaller_remainder_over_a_larger(
|
||||
cents in 0i64..10_000_000_000, shares in arb_shares(),
|
||||
) {
|
||||
let amount = Decimal::new(cents, 2);
|
||||
let parts = ledger_core::periods::allocate(amount, &shares);
|
||||
let exact: Vec<Decimal> = shares.iter().map(|s| amount * s).collect();
|
||||
let floor = |e: &Decimal| e.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::ToZero);
|
||||
for i in 0..parts.len() {
|
||||
for j in 0..parts.len() {
|
||||
let (ri, rj) = (exact[i] - floor(&exact[i]), exact[j] - floor(&exact[j]));
|
||||
let (up_i, up_j) = (parts[i] > floor(&exact[i]), parts[j] > floor(&exact[j]));
|
||||
if ri > rj {
|
||||
prop_assert!(up_i || !up_j, "member {j} rounded up over member {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prop_split_of_a_loss_is_the_negated_split_of_the_gain(
|
||||
cents in 0i64..10_000_000_000, shares in arb_shares(),
|
||||
) {
|
||||
let amount = Decimal::new(cents, 2);
|
||||
let gain = ledger_core::periods::allocate(amount, &shares);
|
||||
let loss = ledger_core::periods::allocate(-amount, &shares);
|
||||
prop_assert_eq!(loss, gain.iter().map(|p| -*p).collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
@@ -888,3 +888,74 @@ fn partner_distribution_accounts_roll_into_capital() {
|
||||
Err(LedgerError::InvalidMembers(_))
|
||||
));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Splitting odd cents among members: largest remainder (owner,
|
||||
// 2026-10-13). Spec: periods::allocate.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn split(amount: &str, shares: &[&str]) -> Vec<Decimal> {
|
||||
let shares: Vec<Decimal> = shares.iter().map(|s| cents(s)).collect();
|
||||
ledger_core::periods::allocate(cents(amount), &shares)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_odd_cent_goes_to_the_largest_remainder_not_the_last_member() {
|
||||
// Three equal partners: the cent goes to the first (ties to the
|
||||
// earlier), where the old rule always handed it to the last.
|
||||
assert_eq!(
|
||||
split("100.00", &["0.3333333333333333333333333333"; 3]),
|
||||
[cents("33.34"), cents("33.33"), cents("33.33")]
|
||||
);
|
||||
// Unequal shares: 6.666... has the larger remainder.
|
||||
assert_eq!(
|
||||
split(
|
||||
"10.00",
|
||||
&[
|
||||
"0.6666666666666666666666666667",
|
||||
"0.3333333333333333333333333333"
|
||||
]
|
||||
),
|
||||
[cents("6.67"), cents("3.33")]
|
||||
);
|
||||
// Equal remainders (0.035 each): ties go to the earlier member.
|
||||
assert_eq!(
|
||||
split("0.07", &["0.5", "0.5"]),
|
||||
[cents("0.04"), cents("0.03")]
|
||||
);
|
||||
// 0.015 and 0.035: half a cent each, a tie again.
|
||||
assert_eq!(
|
||||
split("0.05", &["0.3", "0.7"]),
|
||||
[cents("0.02"), cents("0.03")]
|
||||
);
|
||||
// 0.003 and 0.007: the later member's remainder is larger, so the
|
||||
// cent is theirs.
|
||||
assert_eq!(
|
||||
split("0.01", &["0.3", "0.7"]),
|
||||
[cents("0.00"), cents("0.01")]
|
||||
);
|
||||
// One cent, two ways: someone gets it, nobody gets a fraction.
|
||||
assert_eq!(
|
||||
split("0.01", &["0.5", "0.5"]),
|
||||
[cents("0.01"), cents("0.00")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loss_splits_as_the_negation_of_the_same_gain() {
|
||||
assert_eq!(
|
||||
split("-100.00", &["0.3333333333333333333333333333"; 3]),
|
||||
[cents("-33.34"), cents("-33.33"), cents("-33.33")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closes_journaled_under_the_old_rule_replay_with_it() {
|
||||
// Contract: a close made before 2026-10-13 (last member absorbs) must
|
||||
// replay to the same lines forever.
|
||||
let shares = [cents("0.3333333333333333333333333333"); 3];
|
||||
assert_eq!(
|
||||
ledger_core::periods::allocate_last_absorbs(cents("100.00"), &shares),
|
||||
[cents("33.33"), cents("33.33"), cents("33.34")]
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user