P3: odd cents split the same running way everywhere (defect 17, D-38)
check / check (push) Canceled after 0s
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:
44 files changed
+6636
-195
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user