closes: a run of members closes splits cumulatively
check / check (push) Canceled after 0s

Each close into members' capital rounded on its own, ties to the
earlier member, so twelve monthly 50/50 closes of odd-cent income could
leave one member several cents ahead of half the year (docs/testing.md
#12, found with the members' equity fix).

- CloseTo::Members now splits cumulatively over a run (closes back to
  back with the same members and shares): each close gives a member
  allocate(run total so far) - allocate(run total before). Periods
  record their close order and a cumulative close's members and net
  (Period::close_seq, member_close) so the next close continues the run
  and replay recomputes it exactly. A close to anything else, or other
  shares, ends the run.
- Journaled "rounding": "cumulative". The per-close rule is kept as
  CloseTo::MembersPerClose for closes journaled "largest_remainder";
  older closes still replay with the last member absorbing it.
- Lean: runPart_telescopes (a member's total from a run is their
  allocation of the run's total) and closePart_total (each close hands
  out exactly its net), audited to the standard axioms.
- Tests: hand-computed three one-cent months (per close A 0.03 / B 0;
  cumulative A 0.02 / B 0.01), a run ending, a property that each member
  stays within a cent of their share (fails on the per-close rule), and
  a store contract that each rule journals and replays as itself.

Wanderland's books have no closes yet, so all of theirs use the new rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-13 18:58:21 -06:00
1 parent f4381f170c
commit fb3b4ab476
11 files changed
+416 -16

No files matched your search

+174
View File
@@ -1522,3 +1522,177 @@ mod chaining {
}
}
}
mod cumulative_closes {
use super::*;
use ledger_core::{CloseTo, MemberClose};
/// Three months of one-cent income, 50/50, each month closed into the
/// members' capital.
fn three_cent_months(to: impl Fn(&[AccountId; 2]) -> CloseTo) -> (Ledger, [AccountId; 2]) {
let mut l = Ledger::new(Currency::USD);
let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
let cash = add("1000", AccountType::Asset);
let caps = [
add("3000", AccountType::Equity),
add("3001", AccountType::Equity),
];
let rev = add("4000", AccountType::Revenue);
for m in 1..=3 {
let e = l.create_entry(d(m, 10), "a cent");
l.add_line(
e,
cash,
Side::Debit,
Money::new(cents("0.01"), Currency::USD),
)
.unwrap();
l.add_line(
e,
rev,
Side::Credit,
Money::new(cents("0.01"), Currency::USD),
)
.unwrap();
l.post_entry(e).unwrap();
let end = d(m + 1, 1).pred_opt().unwrap();
let p = l
.create_period(&format!("2026-{m:02}"), d(m, 1), end)
.unwrap();
l.close_period_to(p, &to(&caps)).unwrap();
}
(l, caps)
}
fn half(caps: &[AccountId; 2]) -> Vec<MemberClose> {
caps.iter()
.map(|&capital| MemberClose {
capital,
share: cents("0.5"),
distributions: None,
})
.collect()
}
/// Oracle: rules.md B-13 (cumulative closes; docs/testing.md #12),
/// hand-computed. Closing each cent on its own gives the earlier member
/// every tie: A 0.03, B 0.00 after three months. Cumulatively: after
/// month 1 the run's 0.01 splits A 0.01 / B 0; after month 2, 0.02
/// splits 0.01 / 0.01, so month 2 gives B the cent; after month 3, 0.03
/// splits 0.02 / 0.01, so month 3 gives A the cent. A 0.02, B 0.01.
#[test]
fn a_run_of_closes_never_piles_up_rounding() {
let (l, caps) = three_cent_months(|c| CloseTo::MembersPerClose(half(c)));
let bal = |l: &Ledger, a| l.account_balance(a).unwrap();
assert_eq!(
(bal(&l, caps[0]), bal(&l, caps[1])),
(cents("0.03"), cents("0.00")),
"per close: the earlier member takes every tie"
);
let (l, caps) = three_cent_months(|c| CloseTo::Members(half(c)));
assert_eq!(
(bal(&l, caps[0]), bal(&l, caps[1])),
(cents("0.02"), cents("0.01")),
"cumulative: within a cent of half of 0.03 each"
);
}
proptest::proptest! {
/// Oracle: rules.md B-13, cumulative closes. For any run of monthly
/// profits and losses closed into three members at 33.33% /
/// 33.33% / 33.34%, each member's total from the run is within one
/// cent of their exact share of everything closed.
#[test]
fn each_member_stays_within_a_cent_of_their_share(
nets in proptest::collection::vec(-500_000i64..500_000, 1..12),
) {
let mut l = Ledger::new(Currency::USD);
let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
let cash = add("1000", AccountType::Asset);
let caps = [
add("3000", AccountType::Equity),
add("3001", AccountType::Equity),
add("3002", AccountType::Equity),
];
let rev = add("4000", AccountType::Revenue);
let exp = add("6000", AccountType::Expense);
let shares = [cents("0.3333"), cents("0.3333"), cents("0.3334")];
let to = CloseTo::Members(
caps.iter()
.zip(shares)
.map(|(&capital, share)| MemberClose { capital, share, distributions: None })
.collect(),
);
for (i, n) in nets.iter().enumerate() {
let m = i as u32 + 1;
if *n != 0 {
let amount = Money::new(Decimal::new(n.abs(), 2), Currency::USD);
let (dr, cr) = if *n > 0 { (cash, rev) } else { (exp, cash) };
let e = l.create_entry(d(m, 10), "month");
l.add_line(e, dr, Side::Debit, amount).unwrap();
l.add_line(e, cr, Side::Credit, amount).unwrap();
l.post_entry(e).unwrap();
}
let end = if m == 12 { d(12, 31) } else { d(m + 1, 1).pred_opt().unwrap() };
let p = l.create_period(&format!("2026-{m:02}"), d(m, 1), end).unwrap();
l.close_period_to(p, &to).unwrap();
}
let total = Decimal::new(nets.iter().sum::<i64>(), 2);
for (cap, share) in caps.iter().zip(shares) {
let got = l.account_balance(*cap).unwrap();
proptest::prop_assert!((got - total * share).abs() < cents("0.01"), "{got} vs {}", total * share);
}
}
}
/// A run is closes with the same members and shares, back to back: a
/// close to anything else (or with other shares) ends it, and the next
/// members close starts afresh.
#[test]
fn a_different_close_ends_the_run() {
let mut l = Ledger::new(Currency::USD);
let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
let cash = add("1000", AccountType::Asset);
let caps = [
add("3000", AccountType::Equity),
add("3001", AccountType::Equity),
];
let rev = add("4000", AccountType::Revenue);
let cent = |l: &mut Ledger, m| {
let e = l.create_entry(d(m, 10), "a cent");
l.add_line(
e,
cash,
Side::Debit,
Money::new(cents("0.01"), Currency::USD),
)
.unwrap();
l.add_line(
e,
rev,
Side::Credit,
Money::new(cents("0.01"), Currency::USD),
)
.unwrap();
l.post_entry(e).unwrap();
};
let close = |l: &mut Ledger, m: u32, to: &CloseTo| {
let end = d(m + 1, 1).pred_opt().unwrap();
let p = l
.create_period(&format!("2026-{m:02}"), d(m, 1), end)
.unwrap();
l.close_period_to(p, to).unwrap();
};
// Month 1 to members (A gets the cent), month 2 to retained
// earnings (ends the run), month 3 to members again: a new run, so
// A gets that cent too.
cent(&mut l, 1);
close(&mut l, 1, &CloseTo::Members(half(&caps)));
cent(&mut l, 2);
close(&mut l, 2, &CloseTo::RetainedEarnings { sweep: vec![] });
cent(&mut l, 3);
close(&mut l, 3, &CloseTo::Members(half(&caps)));
assert_eq!(l.account_balance(caps[0]).unwrap(), cents("0.02"));
assert_eq!(l.account_balance(caps[1]).unwrap(), cents("0"));
}
}