fix(core): due-today is Current; odd cents split by largest remainder
Max-testing pass, step 2: two spec decisions Stephen 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
a09c1fb218
commit
0340f316ee
11 files changed
+485
-19
No files matched your search
@@ -194,7 +194,26 @@ impl AgingRow {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bucket a past-due balance by days overdue.
|
||||
/// Put an open balance in its aging bucket.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: say how overdue a balance is, as of a date.
|
||||
/// INVARIANTS:
|
||||
/// 1. MUST put the whole balance in exactly one bucket; the others are
|
||||
/// zero.
|
||||
/// 2. NEVER moves a balance to a younger bucket as `as_of` advances.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF `as_of` is on or before the due date:
|
||||
/// - SHOULD be Current
|
||||
/// - NEVER past due
|
||||
/// BECAUSE a document due today is not yet overdue (QuickBooks'
|
||||
/// convention; Stephen, 2026-10-13).
|
||||
/// 2. IF it is 1 to 30 days past due: SHOULD be 1-30.
|
||||
/// 3. IF 31 to 60: SHOULD be 31-60.
|
||||
/// 4. IF 61 to 90: SHOULD be 61-90.
|
||||
/// 5. IF more than 90: SHOULD be 90+.
|
||||
/// BECAUSE these are the standard 30-day aging buckets.
|
||||
/// ```
|
||||
pub fn aging_bucket(due_date: NaiveDate, as_of: NaiveDate, balance: Decimal) -> AgingRow {
|
||||
let mut row = AgingRow {
|
||||
name: String::new(),
|
||||
@@ -204,7 +223,7 @@ pub fn aging_bucket(due_date: NaiveDate, as_of: NaiveDate, balance: Decimal) ->
|
||||
days_90: Decimal::ZERO,
|
||||
days_90_plus: Decimal::ZERO,
|
||||
};
|
||||
if due_date > as_of {
|
||||
if due_date >= as_of {
|
||||
row.current = balance;
|
||||
} else {
|
||||
let days = (as_of - due_date).num_days();
|
||||
@@ -348,6 +367,58 @@ mod tests {
|
||||
assert!(matches!(err, Err(LedgerError::Overpayment(_, _))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aging_boundaries() {
|
||||
// Spec scenarios 1-5, at every edge.
|
||||
let due = NaiveDate::from_ymd_opt(2026, 3, 31).unwrap();
|
||||
let b = Decimal::from(7);
|
||||
let bucket = |days: i64| {
|
||||
let r = aging_bucket(due, due + chrono::Duration::days(days), b);
|
||||
[r.current, r.days_30, r.days_60, r.days_90, r.days_90_plus]
|
||||
.iter()
|
||||
.position(|x| !x.is_zero())
|
||||
.unwrap()
|
||||
};
|
||||
for (days, want) in [
|
||||
(-10, 0),
|
||||
(0, 0),
|
||||
(1, 1),
|
||||
(30, 1),
|
||||
(31, 2),
|
||||
(60, 2),
|
||||
(61, 3),
|
||||
(90, 3),
|
||||
(91, 4),
|
||||
(4000, 4),
|
||||
] {
|
||||
assert_eq!(bucket(days), want, "{days} days past due");
|
||||
}
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
/// Invariants 1 and 2: one bucket holds the whole balance, and
|
||||
/// time only ever ages it.
|
||||
#[test]
|
||||
fn aging_is_exclusive_and_only_ages(
|
||||
due_off in 0i64..2000, a in 0i64..2000, step in 0i64..400, cents in 1i64..10_000_000,
|
||||
) {
|
||||
let base = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
|
||||
let due = base + chrono::Duration::days(due_off);
|
||||
let b = Decimal::new(cents, 2);
|
||||
let idx = |as_of: NaiveDate| {
|
||||
let r = aging_bucket(due, as_of, b);
|
||||
let cells = [r.current, r.days_30, r.days_60, r.days_90, r.days_90_plus];
|
||||
let held: Vec<usize> = (0..5).filter(|&i| !cells[i].is_zero()).collect();
|
||||
proptest::prop_assert_eq!(held.len(), 1);
|
||||
proptest::prop_assert_eq!(cells[held[0]], b);
|
||||
Ok(held[0])
|
||||
};
|
||||
let early = base + chrono::Duration::days(a);
|
||||
let later = early + chrono::Duration::days(step);
|
||||
proptest::prop_assert!(idx(early)? <= idx(later)?);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ar_aging_buckets_correctly() {
|
||||
let mut l = arap_ledger();
|
||||
|
||||
@@ -268,6 +268,81 @@ pub fn plug_last<T: Amount>(whole: T, parts: &mut [T]) -> Option<()> {
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Largest-remainder apportionment: make rounded-down `parts` sum to
|
||||
/// `whole`, handing out the shortfall one `unit` at a time, largest
|
||||
/// `remainders` first (ties to the earlier part). Any final fraction of a
|
||||
/// unit (when `whole` isn't a whole number of units) goes to the first
|
||||
/// part in that order.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: split a whole into parts, each a whole number of units where
|
||||
/// possible, summing to the whole exactly, without favoring anyone.
|
||||
/// INVARIANTS:
|
||||
/// 1. MUST make the parts sum to `whole` exactly.
|
||||
/// 2. MUST change each part by zero or one unit (the first in order
|
||||
/// may also take the sub-unit fraction).
|
||||
/// 3. NEVER gives a unit to a part while a part with a strictly larger
|
||||
/// remainder goes without.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the shortfall is negative, or `n` units or more (the parts
|
||||
/// weren't rounded down from shares of `whole`):
|
||||
/// - SHOULD return None, leaving `parts` unchanged
|
||||
/// BECAUSE apportioning only corrects rounding.
|
||||
/// 2. IF $100.00 splits three equal ways:
|
||||
/// - SHOULD be 33.34, 33.33, 33.33
|
||||
/// - NEVER the same partner absorbing every odd cent by position
|
||||
/// BECAUSE the cent goes to the largest remainder, ties to the first
|
||||
/// (Stephen, 2026-10-13).
|
||||
/// ```
|
||||
///
|
||||
/// Allocation-free and O(n^2): it ranks each part by counting the parts
|
||||
/// ahead of it, so the model checker can prove it outright.
|
||||
pub fn apportion<T: Amount>(whole: T, parts: &mut [T], remainders: &[T], unit: T) -> Option<()> {
|
||||
if parts.len() != remainders.len() || unit <= T::ZERO {
|
||||
return None;
|
||||
}
|
||||
if parts.is_empty() {
|
||||
// Nothing to give the whole to.
|
||||
return (whole == T::ZERO).then_some(());
|
||||
}
|
||||
let mut shortfall = whole;
|
||||
for &p in parts.iter() {
|
||||
shortfall = shortfall.checked_sub(p)?;
|
||||
}
|
||||
if shortfall < T::ZERO {
|
||||
return None;
|
||||
}
|
||||
// Whole units short, by subtraction: no division in `Amount`. A
|
||||
// shortfall of a unit per part or more isn't rounding; refuse it.
|
||||
let mut units = 0;
|
||||
while shortfall >= unit {
|
||||
shortfall = shortfall.checked_sub(unit)?;
|
||||
units += 1;
|
||||
if units == parts.len() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// A part's rank: the parts ahead of it (larger remainder, or equal and
|
||||
// earlier).
|
||||
let rank = |i: usize| {
|
||||
(0..remainders.len())
|
||||
.filter(|&j| remainders[j] > remainders[i] || (remainders[j] == remainders[i] && j < i))
|
||||
.count()
|
||||
};
|
||||
for (i, part) in parts.iter_mut().enumerate() {
|
||||
let r = rank(i);
|
||||
let mut add = T::ZERO;
|
||||
if r < units {
|
||||
add = unit;
|
||||
}
|
||||
if r == 0 {
|
||||
add = add.checked_add(shortfall)?;
|
||||
}
|
||||
*part = part.checked_add(add)?;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -283,6 +358,23 @@ mod tests {
|
||||
assert!("1.000000000000".parse::<Decimal>().unwrap().in_line_range());
|
||||
}
|
||||
|
||||
/// Regression (Kani `apportion_is_exact`, 2026-10-13): a shortfall of
|
||||
/// two units and a bit, with one part, was accepted and handed almost
|
||||
/// two units to that part. Spec scenario 1: more than one unit short
|
||||
/// per part is refused.
|
||||
#[test]
|
||||
fn apportion_refuses_a_shortfall_of_a_unit_per_part_or_more() {
|
||||
let mut parts = [0i64];
|
||||
assert_eq!(apportion(1805, &mut parts, &[0], 903), None);
|
||||
assert_eq!(parts, [0]);
|
||||
let mut parts = [0i64, 0];
|
||||
assert_eq!(apportion(1806, &mut parts, &[0, 0], 903), None);
|
||||
// Just under: accepted.
|
||||
let mut parts = [0i64, 0];
|
||||
assert_eq!(apportion(1805, &mut parts, &[1, 0], 903), Some(()));
|
||||
assert_eq!(parts, [1805, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credit_line_sides() {
|
||||
assert_eq!(credit_line(5i64), Some((Side::Credit, 5)));
|
||||
|
||||
@@ -926,9 +926,15 @@ impl Ledger {
|
||||
sweep(*a, *into, self);
|
||||
}
|
||||
}
|
||||
periods::CloseTo::Members(members) => {
|
||||
periods::CloseTo::Members(members) | periods::CloseTo::MembersLastAbsorbs(members) => {
|
||||
let shares: Vec<Decimal> = members.iter().map(|m| m.share).collect();
|
||||
for (m, part) in members.iter().zip(periods::allocate(net, &shares)) {
|
||||
let parts = match to {
|
||||
periods::CloseTo::MembersLastAbsorbs(_) => {
|
||||
periods::allocate_last_absorbs(net, &shares)
|
||||
}
|
||||
_ => periods::allocate(net, &shares),
|
||||
};
|
||||
for (m, part) in members.iter().zip(parts) {
|
||||
equity.push((m.capital, part));
|
||||
if let Some(d) = m.distributions {
|
||||
sweep(d, m.capital, self);
|
||||
@@ -1022,7 +1028,7 @@ impl Ledger {
|
||||
accounts.push(*into);
|
||||
accounts.extend(sweep);
|
||||
}
|
||||
periods::CloseTo::Members(members) => {
|
||||
periods::CloseTo::Members(members) | periods::CloseTo::MembersLastAbsorbs(members) => {
|
||||
let total: Decimal = members.iter().map(|m| m.share).sum();
|
||||
if members.is_empty()
|
||||
|| total != Decimal::ONE
|
||||
@@ -1123,7 +1129,7 @@ impl Ledger {
|
||||
let sweeps: Vec<AccountId> = match to {
|
||||
periods::CloseTo::RetainedEarnings { sweep }
|
||||
| periods::CloseTo::Account { sweep, .. } => sweep.clone(),
|
||||
periods::CloseTo::Members(members) => {
|
||||
periods::CloseTo::Members(members) | periods::CloseTo::MembersLastAbsorbs(members) => {
|
||||
members.iter().filter_map(|m| m.distributions).collect()
|
||||
}
|
||||
};
|
||||
|
||||
@@ -50,8 +50,13 @@ pub enum CloseTo {
|
||||
},
|
||||
/// Members' capital accounts by share (shares sum to 1): the
|
||||
/// partnership convention, where each member's capital account carries
|
||||
/// their allocated earnings and absorbs their distributions.
|
||||
/// their allocated earnings and absorbs their distributions. Odd cents
|
||||
/// go by largest remainder ([`allocate`]).
|
||||
Members(Vec<MemberClose>),
|
||||
/// [`CloseTo::Members`] under the rounding closes used before
|
||||
/// 2026-10-13 ([`allocate_last_absorbs`]). Only replay of those journals
|
||||
/// produces it; new closes use `Members`.
|
||||
MembersLastAbsorbs(Vec<MemberClose>),
|
||||
}
|
||||
|
||||
/// One member in a partnership close.
|
||||
@@ -66,11 +71,40 @@ pub struct MemberClose {
|
||||
pub distributions: Option<crate::AccountId>,
|
||||
}
|
||||
|
||||
/// Split `amount` by `shares` to the cent; the last share absorbs rounding,
|
||||
/// so the parts always sum to `amount` exactly.
|
||||
/// Split `amount` by `shares` (which sum to 1) to the cent, by largest
|
||||
/// remainder: every part is its exact share rounded toward zero to the
|
||||
/// cent, and the leftover cents go one each to the largest remainders,
|
||||
/// ties to the earlier member ([`crate::kernel::apportion`]). The parts
|
||||
/// always sum to `amount` exactly, and no member is favored by position
|
||||
/// (Stephen, 2026-10-13). A loss splits as the negation of the same gain.
|
||||
pub fn allocate(
|
||||
amount: rust_decimal::Decimal,
|
||||
shares: &[rust_decimal::Decimal],
|
||||
) -> Vec<rust_decimal::Decimal> {
|
||||
use rust_decimal::{Decimal, RoundingStrategy::ToZero};
|
||||
let magnitude = amount.abs();
|
||||
let exact: Vec<Decimal> = shares.iter().map(|s| magnitude * s).collect();
|
||||
let mut parts: Vec<Decimal> = exact
|
||||
.iter()
|
||||
.map(|e| e.round_dp_with_strategy(2, ToZero))
|
||||
.collect();
|
||||
let remainders: Vec<Decimal> = exact.iter().zip(&parts).map(|(e, p)| e - p).collect();
|
||||
crate::kernel::apportion(magnitude, &mut parts, &remainders, Decimal::new(1, 2))
|
||||
.expect("shares sum to 1, so the shortfall is under one cent per member");
|
||||
if amount.is_sign_negative() {
|
||||
for p in &mut parts {
|
||||
*p = -*p;
|
||||
}
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
/// The rule closes used before 2026-10-13: each share rounded half away
|
||||
/// from zero to the cent, the last absorbing the rounding. Kept only so
|
||||
/// closes journaled under it replay exactly ([`CloseTo::MembersLastAbsorbs`]).
|
||||
pub fn allocate_last_absorbs(
|
||||
amount: rust_decimal::Decimal,
|
||||
shares: &[rust_decimal::Decimal],
|
||||
) -> Vec<rust_decimal::Decimal> {
|
||||
use rust_decimal::RoundingStrategy::MidpointAwayFromZero;
|
||||
let mut parts: Vec<_> = shares
|
||||
|
||||
@@ -460,6 +460,89 @@ fn plug_last_sums_exactly() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Most parts the apportionment harnesses explore (its ranking is
|
||||
/// quadratic, so the bound is smaller than `N`).
|
||||
const AP: usize = 3;
|
||||
|
||||
/// Inputs for `kernel::apportion`: rounded-down parts, remainders, a unit,
|
||||
/// and a whole some shortfall above the parts' sum.
|
||||
fn any_apportionment() -> (usize, [i64; AP], [i64; AP], i64, i64, i64) {
|
||||
let n: usize = kani::any();
|
||||
kani::assume(n <= AP);
|
||||
let before: [i64; AP] = core::array::from_fn(|_| any_amount());
|
||||
let rems: [i64; AP] = core::array::from_fn(|_| any_amount());
|
||||
let unit: i64 = kani::any();
|
||||
kani::assume(unit > 0 && unit <= 1000);
|
||||
let shortfall: i64 = kani::any();
|
||||
kani::assume(shortfall.unsigned_abs() <= 4000);
|
||||
let mut whole = shortfall;
|
||||
for &p in &before[..n] {
|
||||
whole += p;
|
||||
}
|
||||
(n, before, rems, unit, shortfall, whole)
|
||||
}
|
||||
|
||||
/// `kernel::apportion` succeeds exactly when the parts are short of the
|
||||
/// whole by less than one unit per part; then the parts sum to the whole,
|
||||
/// and each gains nothing or one unit, except that one part may also take
|
||||
/// the sub-unit fraction.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(5)]
|
||||
fn apportion_is_exact() {
|
||||
let (n, before, rems, unit, shortfall, whole) = any_apportionment();
|
||||
let mut parts = before;
|
||||
let ok = kernel::apportion(whole, &mut parts[..n], &rems[..n], unit);
|
||||
let in_range = if n == 0 {
|
||||
whole == 0
|
||||
} else {
|
||||
shortfall >= 0 && shortfall < n as i64 * unit
|
||||
};
|
||||
assert_eq!(ok.is_some(), in_range);
|
||||
if ok.is_none() {
|
||||
return;
|
||||
}
|
||||
let mut sum = 0i64;
|
||||
let mut fraction_takers = 0;
|
||||
for i in 0..n {
|
||||
sum += parts[i];
|
||||
let gain = parts[i] - before[i];
|
||||
assert!(gain >= 0 && gain < 2 * unit);
|
||||
if gain != 0 && gain != unit {
|
||||
fraction_takers += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(sum, whole);
|
||||
assert!(fraction_takers <= 1);
|
||||
kani::cover!(
|
||||
n == AP && shortfall >= 2 * unit,
|
||||
"three parts, two units short"
|
||||
);
|
||||
}
|
||||
|
||||
/// `kernel::apportion` never gives a unit to a part while a part with a
|
||||
/// strictly larger remainder goes without (Stephen, 2026-10-13: no member
|
||||
/// favored by position).
|
||||
#[kani::proof]
|
||||
#[kani::unwind(5)]
|
||||
fn apportion_is_fair() {
|
||||
let (n, before, rems, unit, _, whole) = any_apportionment();
|
||||
let mut parts = before;
|
||||
if kernel::apportion(whole, &mut parts[..n], &rems[..n], unit).is_none() {
|
||||
return;
|
||||
}
|
||||
for i in 0..n {
|
||||
for j in 0..n {
|
||||
if rems[i] > rems[j] {
|
||||
assert!(parts[i] - before[i] >= unit || parts[j] - before[j] < unit);
|
||||
}
|
||||
}
|
||||
}
|
||||
kani::cover!(
|
||||
n == AP && rems[0] < rems[2],
|
||||
"a later part with a larger remainder"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The checker itself
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 (Stephen,
|
||||
// 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")]
|
||||
);
|
||||
}
|
||||
@@ -1010,7 +1010,8 @@ impl Store {
|
||||
CloseTo::RetainedEarnings { sweep } if sweep.is_empty() => json!("retained_earnings"),
|
||||
CloseTo::RetainedEarnings { sweep } => json!({"retained_earnings": {"sweep": sweep}}),
|
||||
CloseTo::Account { into, sweep } => json!({"account": {"into": into, "sweep": sweep}}),
|
||||
CloseTo::Members(members) => json!({
|
||||
CloseTo::Members(members) | CloseTo::MembersLastAbsorbs(members) => {
|
||||
let mut v = json!({
|
||||
"members": members
|
||||
.iter()
|
||||
.map(|m| json!({
|
||||
@@ -1019,7 +1020,14 @@ impl Store {
|
||||
"distributions": m.distributions,
|
||||
}))
|
||||
.collect::<Vec<_>>(),
|
||||
}),
|
||||
});
|
||||
// Closes journaled before 2026-10-13 carry no rounding and
|
||||
// replay with the last member absorbing it.
|
||||
if matches!(to, CloseTo::Members(_)) {
|
||||
v["rounding"] = json!("largest_remainder");
|
||||
}
|
||||
v
|
||||
}
|
||||
};
|
||||
let line = self.append_journal(
|
||||
"period.closed",
|
||||
@@ -3403,8 +3411,7 @@ fn close_to_from_journal(v: Option<&Value>) -> Result<CloseTo, StoreError> {
|
||||
.get("members")
|
||||
.and_then(|m| m.as_array())
|
||||
.ok_or_else(|| corrupt("unknown target"))?;
|
||||
Ok(CloseTo::Members(
|
||||
members
|
||||
let members = members
|
||||
.iter()
|
||||
.map(|m| {
|
||||
Ok(ledger_core::MemberClose {
|
||||
@@ -3415,8 +3422,14 @@ fn close_to_from_journal(v: Option<&Value>) -> Result<CloseTo, StoreError> {
|
||||
distributions: m.get("distributions").and_then(|d| d.as_u64()),
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, StoreError>>()?,
|
||||
))
|
||||
.collect::<Result<_, StoreError>>()?;
|
||||
// The rounding rule the close was made under: absent before
|
||||
// 2026-10-13, when the last member absorbed it.
|
||||
match v.get("rounding").and_then(|r| r.as_str()) {
|
||||
None => Ok(CloseTo::MembersLastAbsorbs(members)),
|
||||
Some("largest_remainder") => Ok(CloseTo::Members(members)),
|
||||
Some(_) => Err(corrupt("unknown rounding")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a posted entry that rides inside another event's payload (a
|
||||
|
||||
@@ -63,8 +63,9 @@ requirement conflict, the requirement wins.
|
||||
(loss), distributions (debits), ending — plus a total that equals
|
||||
balance-sheet equity. Earnings not yet distributed (retained earnings and
|
||||
unclosed income) and the period's income are allocated by ownership share
|
||||
to the cent, the last member absorbing rounding, so every row's total is
|
||||
exact. Any equity change the roll-forward doesn't explain is shown as
|
||||
to the cent by largest remainder (the odd cent goes to the largest
|
||||
remainder, ties to the earlier member; Stephen, 2026-10-13), so every
|
||||
row's total is exact. Any equity change the roll-forward doesn't explain is shown as
|
||||
"other", never hidden.
|
||||
- **R-9d — Tax categories.** Revenue and expense accounts map to stable
|
||||
tax categories (`ledger_core::tax::CATEGORIES`: key, label, income /
|
||||
@@ -126,8 +127,9 @@ requirement conflict, the requirement wins.
|
||||
end** (later-dated entries belong to later periods) and puts the net in
|
||||
`to`: `CloseTo::RetainedEarnings` (`3999`, the corporate convention;
|
||||
`close_period` is shorthand) or `CloseTo::Members` (each member's capital
|
||||
account by share, to the cent, the last absorbing rounding: the
|
||||
partnership convention), or `CloseTo::Account` (any equity account, e.g.
|
||||
account by share, to the cent, odd cents by largest remainder: the
|
||||
partnership convention; closes journaled before 2026-10-13 replay with
|
||||
the rule they were made under, the last member absorbing rounding), or `CloseTo::Account` (any equity account, e.g.
|
||||
a sole proprietor's capital). Every target can sweep owner-pay equity
|
||||
accounts — draws, distributions, dividends — into it: a member's
|
||||
distributions account into their own capital. The members' equity
|
||||
|
||||
Reference in new issue
Block a user