P3: odd cents split the same running way everywhere (defect 17, D-38)
check / check (push) Canceled after 0s

Members' shares of earnings are split cumulatively by largest remainder in
the closes, the members' equity statement, the K-1 worksheet and the
viewer, so a member's capital on a given day never moves by a cent when its
month is later closed. Old closes replay as they were; the first P3 close
carries any true-up. Feature members_cumulative. Kani and Lean proofs of
the bounded drift; bookspec examples.

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-20 21:35:00 -06:00
1 parent 2390dc6553
commit a7f3cc09a9
44 files changed
+6636 -195

No files matched your search

@@ -0,0 +1,398 @@
//! The bookspec examples about closes to the members
//! (`bookspec/examples/members-close.*.toml`; P13, D-34; P3 defect 17),
//! checked against the engine on every run, so they can't rot.
//!
//! Each example is a setup, events and expected values worked by hand from
//! its cited source. Its shape is bookspec's (`bookspec/README.md`) with
//! what closes need:
//!
//! - `[[setup.members]]` takes `share`, an exact decimal fraction (the
//! shares sum to 1), beside `name` and `capital_account`;
//! - an event may be a close instead of an entry: `close = { start, to,
//! rounding? }` closes the period from `start` through the event's
//! `date` to `"members"` (by share, in the order listed) or
//! `"retained_earnings"`; `rounding = "per-close"` makes a members close
//! split its own net alone (books from an engine that rounded each close
//! by itself), and the default is the cumulative rule. A close is an
//! event like any other: event N posts entry #N, so a close must have
//! something to close;
//! - an `[[expect]]` may take `after = N`: the books after event N, not
//! after the last;
//! - three queries: `entry` (`entry = N`: its lines, `"<code>.debit"` and
//! `"<code>.credit"` summing each account's lines on that side, every
//! line of the entry named), `balances` (`as_of`: each account named,
//! `"<code>"`, its balance on its normal side, so a member's capital
//! after a loss is negative), and `members_equity` (`from`, `to`: the
//! statement of changes in members' equity, `"<name>.beginning"`,
//! `.contributions`, `.net_income`, `.distributions`, `.other`,
//! `.ending`).
//!
//! A key the shape doesn't have is refused, never ignored.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use ledger_core::{
AccountType, CloseTo, Currency, Ledger, MemberCapital, MemberClose, Money, Side,
members_equity, reports::trial_balance_as_of,
};
use rust_decimal::Decimal;
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Example {
id: String,
title: String,
rule: String,
status: String,
sources: Vec<String>,
tags: Vec<String>,
setup: Setup,
events: Vec<Event>,
expect: Vec<Expect>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Setup {
currency: String,
fiscal_year_start: String,
basis: String,
rounding: String,
accounts: Vec<AccountSpec>,
#[serde(default)]
members: Vec<MemberSpec>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct AccountSpec {
code: String,
name: String,
#[serde(rename = "type")]
kind: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct MemberSpec {
name: String,
capital_account: String,
share: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Event {
date: String,
description: String,
#[serde(default)]
lines: Vec<LineSpec>,
#[serde(default)]
reverses: Option<u64>,
#[serde(default)]
close: Option<CloseSpec>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct LineSpec {
account: String,
side: String,
amount: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CloseSpec {
start: String,
to: String,
#[serde(default)]
rounding: Option<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Expect {
query: String,
#[serde(default)]
from: Option<String>,
#[serde(default)]
to: Option<String>,
#[serde(default)]
as_of: Option<String>,
#[serde(default)]
entry: Option<u64>,
#[serde(default)]
after: Option<usize>,
values: BTreeMap<String, String>,
}
fn examples() -> Vec<PathBuf> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bookspec/examples");
let mut paths: Vec<PathBuf> = std::fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("members-close.") && n.ends_with(".toml"))
})
.collect();
paths.sort();
paths
}
fn date(s: &str) -> NaiveDate {
NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap_or_else(|_| panic!("date {s:?}"))
}
/// An exact decimal string, never a float.
fn exact(s: &str) -> Decimal {
assert!(
!s.contains(['e', 'E']) && s.trim() == s,
"{s:?} is not a decimal string"
);
s.parse()
.unwrap_or_else(|_| panic!("{s:?} is not a decimal"))
}
fn account_type(kind: &str) -> AccountType {
match kind {
"asset" => AccountType::Asset,
"liability" => AccountType::Liability,
"equity" => AccountType::Equity,
"revenue" => AccountType::Revenue,
"expense" => AccountType::Expense,
other => panic!("account type {other:?}"),
}
}
fn side(s: &str) -> Side {
match s {
"debit" => Side::Debit,
"credit" => Side::Credit,
other => panic!("side {other:?}"),
}
}
struct Books<'a> {
ex: &'a Example,
l: Ledger,
currency: Currency,
}
impl Books<'_> {
fn id(&self, code: &str) -> ledger_core::AccountId {
self.l
.chart()
.find_by_code(code)
.unwrap_or_else(|| panic!("{}: no account {code}", self.ex.id))
.id
}
fn members(&self) -> Vec<MemberCapital> {
self.ex
.setup
.members
.iter()
.map(|m| MemberCapital {
name: m.name.clone(),
capital_account: self.id(&m.capital_account),
distribution_account: None,
share: exact(&m.share),
})
.collect()
}
/// Event `n` (1-based), which must post entry #n.
fn apply(&mut self, n: u64, e: &Event) {
let ex = &self.ex.id;
let day = date(&e.date);
let posted = match (&e.close, e.reverses, e.lines.is_empty()) {
(Some(c), None, true) => {
let p = self
.l
.create_period(&format!("event {n}"), date(&c.start), day)
.unwrap();
let members: Vec<MemberClose> = self
.members()
.iter()
.map(|m| MemberClose {
capital: m.capital_account,
share: m.share,
distributions: None,
})
.collect();
let to = match (c.to.as_str(), c.rounding.as_deref()) {
("members", None) => CloseTo::Members(members),
("members", Some("per-close")) => CloseTo::MembersPerClose(members),
("retained_earnings", None) => CloseTo::RetainedEarnings { sweep: vec![] },
other => panic!("{ex}: close {other:?}"),
};
self.l
.close_period_to(p, &to)
.unwrap()
.unwrap_or_else(|| panic!("{ex}: event {n} closes nothing"))
}
(None, Some(k), true) => {
assert!(k >= 1 && k < n, "{ex}: event {n} reverses {k}");
self.l.reverse_entry(k, day, &e.description).unwrap()
}
(None, None, false) => {
let id = self.l.create_entry(day, &e.description);
for line in &e.lines {
let account = self.id(&line.account);
let money = Money::new(exact(&line.amount), self.currency);
self.l
.add_line(id, account, side(&line.side), money)
.unwrap();
}
self.l.post_entry(id).unwrap();
id
}
_ => panic!("{ex}: event {n} is one of lines, reverses or close"),
};
assert_eq!(posted, n, "{ex}: event {n} posted entry {posted}");
}
/// What `q` asks, for each of its keys.
fn answer(&self, q: &Expect) -> BTreeMap<String, Decimal> {
let ex = &self.ex.id;
match q.query.as_str() {
"entry" => {
let id = q
.entry
.unwrap_or_else(|| panic!("{ex}: entry query without entry"));
let mut out = BTreeMap::new();
for line in self.l.get_entry(id).unwrap().lines() {
let code = &self.l.chart().get(line.account).unwrap().code;
let side = match line.side {
Side::Debit => "debit",
Side::Credit => "credit",
};
*out.entry(format!("{code}.{side}")).or_default() += line.amount.amount;
}
out
}
"balances" => {
let as_of = date(q.as_of.as_deref().expect("balances take as_of"));
let tb = trial_balance_as_of(&self.l, as_of);
q.values
.keys()
.map(|code| {
let account = self.l.chart().get(self.id(code)).unwrap();
let (dr, cr) = tb
.lines
.iter()
.find(|l| &l.code == code)
.map_or((Decimal::ZERO, Decimal::ZERO), |l| (l.debit, l.credit));
let normal = match account.account_type.normal_side() {
Side::Debit => dr - cr,
Side::Credit => cr - dr,
};
(code.clone(), normal)
})
.collect()
}
"members_equity" => {
let from = date(q.from.as_deref().expect("members_equity takes from"));
let to = date(q.to.as_deref().expect("members_equity takes to"));
let st = members_equity(&self.l, from, to, &self.members()).unwrap();
let mut out = BTreeMap::new();
for m in &st.members {
for (field, v) in [
("beginning", m.beginning),
("contributions", m.contributions),
("net_income", m.net_income),
("distributions", m.distributions),
("other", m.other),
("ending", m.ending),
] {
out.insert(format!("{}.{field}", m.name), v);
}
}
out
}
other => panic!("{ex}: query {other:?}"),
}
}
fn check(&self, q: &Expect) {
let ex = &self.ex.id;
let got = self.answer(q);
for (key, want) in &q.values {
let got = got
.get(key)
.unwrap_or_else(|| panic!("{ex}: {} has no {key}", q.query));
assert_eq!(*got, exact(want), "{ex}: {} {key}", q.query);
}
if q.query == "entry" {
// Every line named: the entry is exactly what is expected.
let named: Vec<&String> = q.values.keys().collect();
let lines: Vec<&String> = got.keys().collect();
assert_eq!(lines, named, "{ex}: entry {:?}'s lines", q.entry);
}
}
}
/// Every members-close example loads, and the engine computes every value
/// it expects, after the event it names or the last.
#[test]
fn the_members_close_examples_hold() {
let paths = examples();
assert!(paths.len() >= 4, "{} examples", paths.len());
for path in paths {
let text = std::fs::read_to_string(&path).unwrap();
let ex: Example =
toml::from_str(&text).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
assert_eq!(
path.file_stem().unwrap().to_str().unwrap(),
ex.id,
"an example's file is named for its id"
);
assert!(!ex.title.is_empty() && !ex.rule.is_empty() && !ex.sources.is_empty());
assert!(!ex.tags.is_empty());
assert!(["AUTHORITY", "CONVENTION", "CHOICE"].contains(&ex.status.as_str()));
assert_eq!(
(ex.setup.basis.as_str(), ex.setup.rounding.as_str()),
("accrual", "largest-remainder"),
"{}",
ex.id
);
assert!(ex.setup.fiscal_year_start.len() == 5, "{}", ex.id);
let shares: Decimal = ex.setup.members.iter().map(|m| exact(&m.share)).sum();
assert_eq!(shares, Decimal::ONE, "{}: shares", ex.id);
let currency = Currency::new(&ex.setup.currency).unwrap();
let mut books = Books {
ex: &ex,
l: Ledger::new(currency),
currency,
};
for a in &ex.setup.accounts {
books
.l
.add_account(&a.code, &a.name, account_type(&a.kind), None, false)
.unwrap();
}
for q in &ex.expect {
assert!(
q.after.is_none_or(|n| n >= 1 && n <= ex.events.len()),
"{}: after {:?}",
ex.id,
q.after
);
}
for (i, e) in ex.events.iter().enumerate() {
books.apply(i as u64 + 1, e);
for q in ex.expect.iter().filter(|q| q.after == Some(i + 1)) {
books.check(q);
}
}
for q in ex.expect.iter().filter(|q| q.after.is_none()) {
books.check(q);
}
}
}
@@ -0,0 +1,7 @@
# 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 bd44337eb42456f907441e7938e4909e0b66386ce70d26b9b54e7173c4d72fa7 # shrinks to parts = [427221022393, 672290605285], rems = [0, 0, 0, 0, 0], given = [0, 0, 0, 0, 0], short = 99, unpaired = false
+74
View File
@@ -171,4 +171,78 @@ proptest! {
fn line_range_agrees(cents in arb_cents()) {
prop_assert_eq!(cents.in_line_range(), dec(cents).in_line_range());
}
#[test]
fn apportion_cumulative_agrees(
parts in prop::collection::vec(arb_cents(), 0..5),
rems in prop::collection::vec(0i64..100, 5),
given in prop::collection::vec(arb_cents(), 5),
short in 0i64..500,
unpaired in any::<bool>(),
) {
let n = parts.len();
let whole: i64 = parts.iter().sum::<i64>() + short;
// Past both line ranges the two differ by design (i64's is 2^40
// units, Decimal's a quadrillion).
prop_assume!(whole.unsigned_abs() <= MAX_CENTS as u64);
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();
// Sometimes one `given` too many: both refuse.
let gi = &given[..(n + usize::from(unpaired)).min(5)];
let gd: Vec<Decimal> = gi.iter().copied().map(dec).collect();
let oi = kernel::apportion_cumulative(whole, &mut pi, ri, 100, gi);
let od = kernel::apportion_cumulative(dec(whole), &mut pd, &rd, dec(100), &gd);
prop_assert_eq!(oi.is_some(), od.is_some());
prop_assert_eq!(pi.into_iter().map(dec).collect::<Vec<_>>(), pd);
}
/// periods::allocate_cumulative, on Decimal shares w_i / W (exact:
/// W is 100 or 10,000), against its integer model at cents: floors
/// |T|·w_i div W, remainders |T|·w_i mod W, kernel::apportion_cumulative
/// at i64, a loss as the negated gain (the model Kani's
/// cumulative_close_never_drifts_a_unit_at_* and Lean's allocW prove).
#[test]
fn allocate_cumulative_agrees_with_the_integer_model(
four_places in any::<bool>(),
cuts in prop::collection::vec(0i64..=10_000, 0..3),
closed in -10_000_000i64..10_000_000,
net in -10_000_000i64..10_000_000,
spread in prop::collection::vec(-5_000i64..5_000, 3),
) {
// Weights summing to W: the gaps between sorted cut points.
let big_w: i64 = if four_places { 10_000 } else { 100 };
let mut points: Vec<i64> = cuts.iter().map(|c| c % (big_w + 1)).collect();
points.push(0);
points.push(big_w);
points.sort_unstable();
let weights: Vec<i64> = points.windows(2).map(|w| w[1] - w[0]).filter(|&w| w > 0).collect();
let n = weights.len();
let scale = if four_places { 4 } else { 2 };
let shares: Vec<Decimal> = weights.iter().map(|&w| Decimal::new(w, scale)).collect();
// What earlier closes gave: anything summing to what they closed.
let mut given: Vec<i64> = spread[..n - 1].to_vec();
given.push(closed - given.iter().sum::<i64>());
let total = closed + net;
let got = ledger_core::periods::allocate_cumulative(
dec(total),
&shares,
&given.iter().copied().map(dec).collect::<Vec<_>>(),
2,
)
.unwrap();
let magnitude = total.abs();
let mut parts: Vec<i64> = weights.iter().map(|w| magnitude * w / big_w).collect();
let rems: Vec<i64> = weights.iter().map(|w| magnitude * w % big_w).collect();
let against: Vec<i64> = given.iter().map(|&g| if total < 0 { -g } else { g }).collect();
kernel::apportion_cumulative(magnitude, &mut parts, &rems, 1, &against).unwrap();
let want: Vec<Decimal> = parts.iter().map(|&p| dec(if total < 0 { -p } else { p })).collect();
prop_assert_eq!(&got, &want);
prop_assert_eq!(got.iter().sum::<Decimal>(), dec(net));
for ((g, p), w) in given.iter().zip(&got).zip(&weights) {
let drift = (dec(*g) + p) * Decimal::from(big_w) - dec(total) * Decimal::from(*w);
prop_assert!(drift.abs() < Decimal::from(big_w) / Decimal::from(100), "{drift}");
}
}
}
@@ -0,0 +1,9 @@
# 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 a0b16d56ce3a210978ce651702fc7c5596cfb0c7d64cd5d611d50be203ad9ec0 # shrinks to months = [(0, 0, 0, 16009, 50271), (0, 0, 0, 3114, 37245)], kinds = [Open, Reversed, Members, Members, Members, Members, Members, Members, Members, Members, Members, Members], split = 1
cc 10fbfe6e4565f45a6d1d71fd7b7645ae57d9c720d7de39d603a93a61f90622cb # shrinks to months = [(0, 0, 0, 20832, 1771), (0, 0, 0, 25554, 22500), (0, 0, 0, 69131, 5447), (0, 0, 0, 12460, 27233), (0, 0, 0, 57651, 18336), (0, 0, 0, 12444, 27), (0, 0, 0, 0, 0)], kinds = [Members, Members, Members, Members, Retained, Members, Members, Members, Members, Members, Members, Members], split = 1
cc 477dcd5ca8c61a8af904ba1b3186d03816172fc4287df775f424503268ac17a9 # shrinks to months = [(0, 0, 0, 1730, 13639), (0, 0, 0, 5261, 12342), (0, 0, 0, 4627, 5345), (0, 0, 0, 5564, 3560), (0, 0, 0, 24083, 44249), (0, 0, 0, 5576, 93)], kinds = [Members, Members, Members, Members, Retained, Reversed, Members, Members, Members, Members, Members, Members], split = 1
File diff suppressed because it is too large. Load diff
+47 -11
View File
@@ -667,9 +667,13 @@ fn a_partnership_closes_into_members_capital() {
cents("733.33")
)
);
// February's 100.01 isn't closed yet: each member's share of it is
// what February's close will give them (D-38): the running split of
// 666.67 + 100.01 = 766.68 is 383.34 each, less January's 333.34 /
// 333.33, so Alice 50.00 and Bob 50.01. Bob ends 733.33 − 100 + 50.01.
let feb_me = members_equity(&l, d(2, 1), d(2, 28), &members).unwrap();
assert_eq!(feb_me.members[0].beginning, cents("933.34"));
assert_eq!(feb_me.members[1].ending, cents("683.33"));
assert_eq!(feb_me.members[1].ending, cents("683.34"));
assert_eq!(
feb_me.total.ending,
balance_sheet_as_of(&l, d(2, 28)).unwrap().total_equity
@@ -1595,6 +1599,14 @@ mod cumulative_closes {
(cents("0.02"), cents("0.01")),
"cumulative: within a cent of half of 0.03 each"
);
// An unbroken run splits the same under the run rule of
// 2026-10-13 (journaled "cumulative"), which still replays.
let (l, caps) = three_cent_months(|c| CloseTo::MembersRun(half(c)));
assert_eq!(
(bal(&l, caps[0]), bal(&l, caps[1])),
(cents("0.02"), cents("0.01")),
"the run rule"
);
}
proptest::proptest! {
@@ -1645,11 +1657,34 @@ mod cumulative_closes {
}
}
/// 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.
/// Under the run rule of 2026-10-13 (CloseTo::MembersRun, journaled
/// "cumulative"; only replay makes it now), a run is closes with the
/// same members and shares, back to back: a close to anything else ends
/// it, and the next members close starts afresh, so a tied cent can go
/// to the same member again (P3 defect 17). Closes journaled under it
/// still replay that way.
#[test]
fn a_different_close_ends_the_run() {
fn a_different_close_ends_a_legacy_run() {
let (a, b) = cent_months_around_a_retained_earnings_close(CloseTo::MembersRun);
assert_eq!((a, b), (cents("0.02"), cents("0")));
}
/// The rule since P3 (rules.md B-13, D-10): a close to retained
/// earnings allocates nothing to the members and is skipped, so March's
/// members close continues January's count: the running 0.02 splits
/// 0.01 / 0.01, and March gives B the cent.
#[test]
fn a_different_close_no_longer_restarts_the_count() {
let (a, b) = cent_months_around_a_retained_earnings_close(CloseTo::Members);
assert_eq!((a, b), (cents("0.01"), cents("0.01")));
}
/// A cent in each of January, February and March, 50/50; January and
/// March close to the members with `to`, February to retained
/// earnings. The members' capital balances after March.
fn cent_months_around_a_retained_earnings_close(
to: fn(Vec<MemberClose>) -> CloseTo,
) -> (Decimal, Decimal) {
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);
@@ -1684,15 +1719,16 @@ mod cumulative_closes {
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.
// earnings, month 3 to members again.
cent(&mut l, 1);
close(&mut l, 1, &CloseTo::Members(half(&caps)));
close(&mut l, 1, &to(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"));
close(&mut l, 3, &to(half(&caps)));
(
l.account_balance(caps[0]).unwrap(),
l.account_balance(caps[1]).unwrap(),
)
}
}