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

+303 -7
View File
@@ -51,11 +51,30 @@ 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. Odd cents
/// go by largest remainder ([`allocate`]), cumulatively: a run of
/// member closes with the same members and shares gives each member,
/// in total, their share of the run's total to the cent, so rounding
/// never piles up close after close (rules.md B-13; since 2026-10-13).
/// go by largest remainder, cumulatively from the first members close
/// with these members and shares ([`allocate_cumulative`]): each close
/// gives a member their share of everything closed to the members so
/// far, to the currency's minor unit, less what earlier closes gave
/// them, so no member is ever a minor unit from their exact share
/// (rules.md B-13; D-10). What new closes are made with; the books
/// journal it as [`CloseTo::MembersCumulative`].
Members(Vec<MemberClose>),
/// [`CloseTo::Members`] as journaled: the count of members closes
/// starts at the close numbered `since_seq` (its
/// [`Period::close_seq`]). Replay produces it and checks the start is
/// the one the books derive.
MembersCumulative {
/// The members and shares.
members: Vec<MemberClose>,
/// The close the count starts at: the first members close with
/// these members and shares since the last with others.
since_seq: u64,
},
/// The run rule of 2026-10-13 to P3 (journaled `"cumulative"`): a
/// back-to-back run of members closes with the same members and shares
/// in the same order splits cumulatively, and any other close ends the
/// run. Only replay of those journals produces it.
MembersRun(Vec<MemberClose>),
/// [`CloseTo::Members`] rounding each close on its own (largest
/// remainder), the rule for closes journaled on 2026-10-13 before the
/// cumulative one. Only replay of those journals produces it.
@@ -66,6 +85,21 @@ pub enum CloseTo {
MembersLastAbsorbs(Vec<MemberClose>),
}
impl CloseTo {
/// The members a close splits the net among, under whichever rule;
/// `None` for a close to retained earnings or one account.
pub fn members(&self) -> Option<&[MemberClose]> {
match self {
CloseTo::RetainedEarnings { .. } | CloseTo::Account { .. } => None,
CloseTo::Members(members)
| CloseTo::MembersCumulative { members, .. }
| CloseTo::MembersRun(members)
| CloseTo::MembersPerClose(members)
| CloseTo::MembersLastAbsorbs(members) => Some(members),
}
}
}
/// One member in a partnership close.
#[derive(Clone, Debug, PartialEq)]
pub struct MemberClose {
@@ -84,6 +118,8 @@ pub struct MemberClose {
/// ties to the earlier member ([`crate::kernel::apportion`]). The parts
/// always sum to `amount` exactly, and no member is favored by position
/// (owner, 2026-10-13). A loss splits as the negation of the same gain.
/// Closes journaled under the rules of 2026-10-13 replay with it, in cents
/// whatever the currency; new closes split with [`allocate_cumulative`].
pub fn allocate(
amount: rust_decimal::Decimal,
shares: &[rust_decimal::Decimal],
@@ -122,6 +158,104 @@ pub fn allocate_last_absorbs(
parts
}
/// What one members close gives each member: [`allocate`]'s split of the
/// running `total` (everything closed to these members and shares,
/// this close included), to `places` decimal places
/// ([`crate::Currency::allocation_places`]) instead of cents, less what
/// earlier closes gave each member (`given`, in the order of `shares`).
/// Each member's running total is then one split of the running total
/// (rules.md B-13; [`crate::kernel::apportion_cumulative`]). A loss is
/// the negation of the same gain: it splits |total| against `given`
/// negated, then negates.
///
/// ```text
/// GOAL: no member's running share of earnings is ever a minor unit from
/// their exact share, whatever earlier closes gave them.
/// SOURCE: docs/spec/rules.md B-13 (26 U.S.C. §704(a); Treas. Reg.
/// §1.704-1(b)(2)(iv)(b); largest remainder: DECISION, owner
/// 2026-10-13; from the first close: D-10); B-3 (the minor unit).
/// INVARIANTS:
/// 1. The parts MUST sum to total − Σ given exactly.
/// 2. given[i] + part[i] MUST be member i's largest-remainder split of
/// total to `places` (ties to the earlier member): one split.
/// 3. So when total is a whole number of units of `places` (every posted
/// line is at its currency's allocation_places: its minor unit, B-3,
/// or for a currency without one the finest a line carries),
/// |given[i] + part[i] − total × share[i]| is under one unit (Lean
/// allocW_drift, cumulative_drift; Kani
/// cumulative_close_never_drifts_a_unit_at_*).
/// 4. NEVER rounds a product: if |total| × share isn't exact in Decimal
/// (a share of many decimal places times a large total), refuse
/// (TooLargeToCompute): a rounded product could rank the remainders
/// wrong, and the bound above assumes exact ones (P3 review R-5).
/// SCENARIOS:
/// 1. IF 101 yen splits 0.5 / 0.25 / 0.25 with nothing given: SHOULD be
/// 51 / 25 / 25 (exact 50.5 / 25.25 / 25.25; the yen to the largest
/// remainder), never fractions of a yen.
/// 2. IF 0.03 was given as A 0.03 / B 0.00 (50/50) and the total is now
/// 0.04: SHOULD be A −0.01, B +0.02.
/// 3. IF the total is a loss: SHOULD be the negation of the same gain
/// against the negated given.
/// 4. IF the shares and given don't pair up, or places exceeds what a
/// line may carry: SHOULD refuse.
/// ```
pub fn allocate_cumulative(
total: rust_decimal::Decimal,
shares: &[rust_decimal::Decimal],
given: &[rust_decimal::Decimal],
places: u32,
) -> Result<Vec<rust_decimal::Decimal>, LedgerError> {
use rust_decimal::{Decimal, RoundingStrategy::ToZero};
if shares.len() != given.len() {
return Err(LedgerError::InvalidMembers(format!(
"{} shares but {} earlier parts",
shares.len(),
given.len()
)));
}
if places > crate::kernel::MAX_LINE_SCALE {
return Err(LedgerError::TooLargeToCompute(format!(
"a split to {places} decimal places"
)));
}
let loss = total < Decimal::ZERO;
let magnitude = total.abs().normalize();
let given: Vec<Decimal> = given.iter().map(|&g| if loss { -g } else { g }).collect();
let mut parts = Vec::with_capacity(shares.len());
let mut remainders = Vec::with_capacity(shares.len());
for share in shares {
let share = share.normalize();
// Exact exactly when Decimal kept every digit: a product it had to
// round comes back with fewer places than its factors carry (a
// zero product, which it returns without places, is exact).
let exact = magnitude
.checked_mul(share)
.filter(|e| e.is_zero() || e.scale() == magnitude.scale() + share.scale())
.ok_or_else(|| {
LedgerError::TooLargeToCompute(format!(
"a member's share {share} of {magnitude}, to its last digit,"
))
})?;
let floor = exact.round_dp_with_strategy(places, ToZero);
parts.push(floor);
remainders.push(exact - floor);
}
crate::kernel::apportion_cumulative(
magnitude,
&mut parts,
&remainders,
Decimal::new(1, places),
&given,
)
.ok_or_else(|| LedgerError::TooLargeToCompute(format!("the members' shares of {total}")))?;
if loss {
for p in parts.iter_mut().filter(|p| !p.is_zero()) {
*p = -*p;
}
}
Ok(parts)
}
/// Code of the Retained Earnings account that closing entries credit (or
/// debit), created on the first close if the chart lacks it.
pub const RETAINED_EARNINGS_CODE: &str = "3999";
@@ -145,13 +279,23 @@ pub struct Period {
/// closed.
#[serde(default)]
pub close_seq: Option<u64>,
/// What a cumulative members close allocated, once closed that way.
/// What a close under the run rule ([`CloseTo::MembersRun`], journaled
/// `"cumulative"`) allocated, once closed that way. Only those closes
/// have one: its serialized form is what earlier versions wrote, and
/// stays so (rules.md B-28). Every members close's split, under any
/// rule, is [`Period::members_closed`].
#[serde(default)]
pub member_close: Option<MemberCloseRecord>,
/// What a members close (any rule) split, once closed to the members.
/// Derived on replay; never serialized, so a period reads as earlier
/// versions wrote it.
#[serde(skip)]
pub members_closed: Option<MembersClosed>,
}
/// A cumulative members close ([`CloseTo::Members`]): who shared, and the
/// net it closed. The next close with the same members continues the run.
/// A run-rule members close ([`CloseTo::MembersRun`]): who shared, and the
/// net it closed. The next run-rule close with the same members continues
/// the run.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct MemberCloseRecord {
/// (capital account, share), in the order given.
@@ -160,6 +304,63 @@ pub struct MemberCloseRecord {
pub net: rust_decimal::Decimal,
}
/// The rounding rule a members close was made under, as journaled.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MemberRule {
/// The last member absorbed the rounding (closes before 2026-10-13;
/// [`CloseTo::MembersLastAbsorbs`]).
LastAbsorbs,
/// Largest remainder, each close on its own (2026-10-13;
/// [`CloseTo::MembersPerClose`]).
PerClose,
/// Cumulative over a back-to-back run (2026-10-13 to P3;
/// [`CloseTo::MembersRun`]).
Run,
/// Cumulative from the members close numbered `since_seq`
/// ([`CloseTo::Members`], [`CloseTo::MembersCumulative`]).
Cumulative {
/// The close the count starts at.
since_seq: u64,
},
}
/// A members close's split, under any rule ([`Period::members_closed`]).
#[derive(Clone, Debug, PartialEq)]
pub struct MembersClosed {
/// (capital account, share), in the close's order.
pub members: Vec<(crate::AccountId, rust_decimal::Decimal)>,
/// The net income this close allocated (negative for a loss).
pub net: rust_decimal::Decimal,
/// The income it credited each member's capital account (debited for
/// a negative part), in the same order: their share of `net` plus any
/// true-up. All zero when the close posted no entry.
pub parts: Vec<rust_decimal::Decimal>,
/// The rule it was made under.
pub rule: MemberRule,
}
/// One member's part of a close under the cumulative rule
/// ([`crate::Ledger::member_close_split`]).
#[derive(Clone, Debug, PartialEq)]
pub struct MemberSplit {
/// The member's capital account.
pub capital: crate::AccountId,
/// Their share.
pub share: rust_decimal::Decimal,
/// What this close credited them (negative: debited), any true-up of
/// earlier rounding included.
pub part: rust_decimal::Decimal,
/// What the count's closes have credited them in all, this one
/// included.
pub running: rust_decimal::Decimal,
/// Their exact share of everything the count has closed, this close
/// included (share × total, unrounded): `running` is within one minor
/// unit of it once a close of the count has posted under the
/// cumulative rule (until then it still carries the rounding of closes
/// made under an earlier rule, which that close trues up).
pub exact: rust_decimal::Decimal,
}
impl Period {
/// True when `date` falls inside the period (inclusive).
pub fn contains(&self, date: NaiveDate) -> bool {
@@ -193,6 +394,101 @@ pub fn validate_period_order(start: NaiveDate, end: NaiveDate) -> Result<(), Led
mod tests {
use super::*;
use crate::{Currency, Ledger};
use rust_decimal::Decimal;
fn dec(s: &str) -> Decimal {
s.parse().unwrap()
}
fn decs(v: &[&str]) -> Vec<Decimal> {
v.iter().map(|s| dec(s)).collect()
}
/// allocate_cumulative scenario 1: 101 yen at 0.5 / 0.25 / 0.25 is
/// exactly 50.5 / 25.25 / 25.25; floors 50 / 25 / 25 leave one yen,
/// which goes to the largest remainder (0.5): 51 / 25 / 25. Never a
/// fraction of a yen. A dinar splits to thousandths.
#[test]
fn allocate_cumulative_splits_to_the_currencys_minor_unit() {
let shares = decs(&["0.5", "0.25", "0.25"]);
let none = decs(&["0", "0", "0"]);
assert_eq!(
allocate_cumulative(dec("101"), &shares, &none, 0).unwrap(),
decs(&["51", "25", "25"])
);
// KWD: 0.101 at 50/50 is 0.0505 each; floors 0.050 / 0.050 and
// the tied thousandth to the first.
let half = decs(&["0.5", "0.5"]);
assert_eq!(
allocate_cumulative(dec("0.101"), &half, &decs(&["0", "0"]), 3).unwrap(),
decs(&["0.051", "0.050"])
);
// Cents, as allocate splits them.
assert_eq!(
allocate_cumulative(dec("0.07"), &half, &decs(&["0", "0"]), 2).unwrap(),
allocate(dec("0.07"), &half)
);
}
/// allocate_cumulative scenarios 2 and 3: three cents given A 0.03 /
/// B 0.00 and a running 0.04 give A −0.01, B +0.02; a loss of the
/// same is the negation against the negated given.
#[test]
fn allocate_cumulative_trues_up_and_mirrors_a_loss() {
let half = decs(&["0.5", "0.5"]);
assert_eq!(
allocate_cumulative(dec("0.04"), &half, &decs(&["0.03", "0"]), 2).unwrap(),
decs(&["-0.01", "0.02"])
);
assert_eq!(
allocate_cumulative(dec("-0.04"), &half, &decs(&["-0.03", "0"]), 2).unwrap(),
decs(&["0.01", "-0.02"])
);
// Given that sums to the earlier total: the parts sum to the new
// amount (0.05 − 0.03 = 0.02); running totals 0.03 / 0.02.
let parts = allocate_cumulative(dec("0.05"), &half, &decs(&["0.01", "0.02"]), 2).unwrap();
assert_eq!(parts, decs(&["0.02", "0.00"]));
assert_eq!(parts.iter().sum::<Decimal>(), dec("0.02"));
}
/// allocate_cumulative INVARIANT 4 (P3 review R-5): a share of 28
/// decimal places times 100.00 has 30, more than Decimal keeps, so the
/// product would be rounded: refused, never split from a rounded
/// product. Four places (33.33%) are exact at any line amount.
#[test]
fn allocate_cumulative_refuses_a_product_it_cannot_compute_exactly() {
let third = dec("0.3333333333333333333333333333");
let last = Decimal::ONE - third - third;
let shares = [third, third, last];
let none = decs(&["0", "0", "0"]);
assert!(matches!(
allocate_cumulative(dec("100.00"), &shares, &none, 2),
Err(LedgerError::TooLargeToCompute(_))
));
let shares = decs(&["0.3333", "0.3333", "0.3334"]);
let max = crate::kernel::MAX_LINE_AMOUNT - dec("0.01");
let parts = allocate_cumulative(max, &shares, &none, 2).unwrap();
assert_eq!(parts.iter().sum::<Decimal>(), max);
// Trailing zeros are not digits: "0.50" and "0.5" split alike.
assert_eq!(
allocate_cumulative(dec("0.01"), &decs(&["0.50", "0.50"]), &decs(&["0", "0"]), 2)
.unwrap(),
decs(&["0.01", "0.00"])
);
}
/// allocate_cumulative scenario 4.
#[test]
fn allocate_cumulative_refuses_unpaired_inputs() {
assert!(matches!(
allocate_cumulative(dec("1"), &decs(&["1"]), &[], 2),
Err(LedgerError::InvalidMembers(_))
));
assert!(matches!(
allocate_cumulative(dec("1"), &decs(&["1"]), &decs(&["0"]), 11),
Err(LedgerError::TooLargeToCompute(_))
));
}
fn period_ledger() -> Ledger {
let mut l = Ledger::new(Currency::USD);