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

+34 -16
View File
@@ -32,7 +32,8 @@
//! with the MacBook read as cash.)
//! 4. Within one entry, the property (at most the members' credits in
//! it) is shared among the members credited in proportion to their
//! credits, to the cent, leftover cents by largest remainder
//! credits, to the base currency's minor unit (cents, whole yen),
//! leftover units by largest remainder
//! (kernel::apportion, ties to the earlier member): each takes at
//! most its own credit, and the split depends on the order the
//! members are given only for a tied leftover cent (never on line
@@ -167,6 +168,7 @@ pub fn k1_detail(
));
}
let me = members_equity(ledger, start, end, members)?;
let places = ledger.base_currency().allocation_places();
let receivables = ledger.role_account(&crate::RECEIVABLES);
let closings = ledger.closing_entry_ids();
let cancelled = crate::reports::cancelled_in_span(ledger, start, end);
@@ -219,7 +221,7 @@ pub fn k1_detail(
.iter()
.map(|m| sum(e, Side::Credit, m.capital_account))
.collect();
let takes = share_property(pool, &credits)?;
let takes = share_property(pool, &credits, places)?;
for (i, m) in members.iter().enumerate() {
let (credit, take) = (credits[i], takes[i]);
if !(credit - take).is_zero() {
@@ -274,11 +276,16 @@ pub fn k1_detail(
/// One entry's property shared among the members' credits in it
/// (INVARIANT 4): at most their total credit, in proportion to each
/// credit, rounded toward zero to the cent, the leftover cents by largest
/// remainder. Each part is at most its own credit.
/// credit, rounded toward zero to `places` (the base currency's
/// Currency::allocation_places: cents, whole yen), the leftover units by
/// largest remainder. Each part is at most its own credit.
/// Refused when whole × credit is beyond exact arithmetic (credits near
/// the line limit; P0 review round 3, S1).
fn share_property(pool: Decimal, credits: &[Decimal]) -> Result<Vec<Decimal>, LedgerError> {
fn share_property(
pool: Decimal,
credits: &[Decimal],
places: u32,
) -> Result<Vec<Decimal>, LedgerError> {
use rust_decimal::RoundingStrategy::ToZero;
let too_large =
|| LedgerError::TooLargeToCompute("a member's share of contributed property".into());
@@ -291,7 +298,7 @@ fn share_property(pool: Decimal, credits: &[Decimal]) -> Result<Vec<Decimal>, Le
return Ok(vec![Decimal::ZERO; credits.len()]);
}
// whole × credit is exact; ÷ total is exact whenever the share is a
// whole number of cents, so truncating never loses a cent it is owed.
// whole number of units, so truncating never loses a unit it is owed.
let exact: Vec<Decimal> = credits
.iter()
.map(|c| whole.checked_mul(*c).and_then(|x| x.checked_div(total)))
@@ -299,11 +306,11 @@ fn share_property(pool: Decimal, credits: &[Decimal]) -> Result<Vec<Decimal>, Le
.ok_or_else(too_large)?;
let mut parts: Vec<Decimal> = exact
.iter()
.map(|x| x.round_dp_with_strategy(2, ToZero))
.map(|x| x.round_dp_with_strategy(places, ToZero))
.collect();
let remainders: Vec<Decimal> = exact.iter().zip(&parts).map(|(x, p)| x - p).collect();
crate::kernel::apportion(whole, &mut parts, &remainders, Decimal::new(1, 2))
.expect("each part is short by under a cent, so the shortfall is under a cent per member");
crate::kernel::apportion(whole, &mut parts, &remainders, Decimal::new(1, places))
.expect("each part is short by under a unit, so the shortfall is under a unit per member");
Ok(parts)
}
@@ -840,20 +847,20 @@ mod tests {
flipped.reverse();
assert_eq!(split(&bk, &flipped), vec![each, each]);
assert_eq!(
share_property(d("401"), &[d("750"), d("250")]).unwrap(),
share_property(d("401"), &[d("750"), d("250")], 2).unwrap(),
vec![d("300.75"), d("100.25")]
);
assert_eq!(
share_property(d("0.01"), &[d("5"), d("5")]).unwrap(),
share_property(d("0.01"), &[d("5"), d("5")], 2).unwrap(),
vec![d("0.01"), d("0.00")]
);
// More property than credit (part financed): at most the credits.
assert_eq!(
share_property(d("900"), &[d("300"), d("100")]).unwrap(),
share_property(d("900"), &[d("300"), d("100")], 2).unwrap(),
vec![d("300"), d("100")]
);
assert_eq!(
share_property(d("50"), &[d("0"), d("0")]).unwrap(),
share_property(d("50"), &[d("0"), d("0")], 2).unwrap(),
vec![d("0"), d("0")]
);
// Credits near the line limit: whole x credit is past exact
@@ -861,18 +868,29 @@ mod tests {
// round 3, S1); at a quarter of it, it still splits exactly.
let near = d("900000000000000");
assert!(matches!(
share_property(near, &[near, near]),
share_property(near, &[near, near], 2),
Err(LedgerError::TooLargeToCompute(_))
));
assert!(matches!(
share_property(near, &[Decimal::MAX, Decimal::MAX]),
share_property(near, &[Decimal::MAX, Decimal::MAX], 2),
Err(LedgerError::TooLargeToCompute(_))
));
let quarter = d("250000000000");
assert_eq!(
share_property(quarter, &[quarter, quarter]).unwrap(),
share_property(quarter, &[quarter, quarter], 2).unwrap(),
vec![d("125000000000"), d("125000000000")]
);
// To the base currency's minor unit (allocation_places): in whole
// yen, 101 of property by credits of 51 each is 50.5 each, the
// tied yen to the first; to cents it would be 50.50 each.
assert_eq!(
share_property(d("101"), &[d("51"), d("51")], 0).unwrap(),
vec![d("51"), d("50")]
);
assert_eq!(
share_property(d("101"), &[d("51"), d("51")], 2).unwrap(),
vec![d("50.50"), d("50.50")]
);
}
/// member_party doc: the party of that name with the member role only.
+103 -1
View File
@@ -26,7 +26,10 @@
//! columns differ by the net of all movements (B-6);
//! * a closing line zeroes the account it closes (B-11);
//! * an allocation's parts sum to the whole, to the last unit, by largest
//! remainder (B-13).
//! remainder (B-13);
//! * a cumulative allocation hands out exactly the new amount, and leaves
//! each running total one allocation of everything allocated, so it never
//! drifts a unit from its exact share (B-13).
use crate::{AccountType, Side};
use rust_decimal::Decimal;
@@ -352,6 +355,60 @@ pub fn apportion<T: Amount>(whole: T, parts: &mut [T], remainders: &[T], unit: T
Some(())
}
/// Cumulative apportionment: what one close gives each part when the
/// running `total` is apportioned afresh ([`apportion`]) and what earlier
/// closes gave each part (`given`) is taken back. `parts` and
/// `remainders` are the rounded-down shares of `total` and what rounding
/// took from each, as for [`apportion`].
///
/// ```text
/// GOAL: members' running totals that never drift a unit from their
/// exact shares, whatever earlier closes did.
/// SOURCE: docs/spec/rules.md B-13 (shares: 26 U.S.C. §704(a); capital
/// accounts carry the allocations: Treas. Reg.
/// §1.704-1(b)(2)(iv)(b); cent rounding: DECISION, owner
/// 2026-10-13; counted from the first close: D-10, 2026-10-16).
/// INVARIANTS:
/// 1. MUST make the parts sum to `total − Σ given` exactly.
/// 2. MUST leave given[i] + parts[i] equal to what `apportion(total, …)`
/// gives part i: a running total is one apportionment, so it carries
/// one apportionment's rounding, never a sum of them.
/// 3. On None, `parts` MUST be unchanged.
/// SCENARIOS:
/// 1. IF the lengths differ, the unit isn't positive, `total`, the
/// unit, a part or a `given` is out of line range, or apportion
/// refuses: SHOULD return None, leaving `parts` unchanged
/// BECAUSE in range, the subtraction can't overflow, so a refusal is
/// known before anything changes.
/// 2. IF 0.03 was closed as A 0.03 / B 0 and the running total is now
/// 0.04 (50/50, floors 0.02 / 0.02): SHOULD give A −0.01, B +0.02
/// BECAUSE 0.04 apportions 0.02 each.
/// ```
///
/// Allocation-free, with no bulk copy (docs/verification.md, "Trusting
/// the checker"), so the model checker can prove it outright.
pub fn apportion_cumulative<T: Amount>(
total: T,
parts: &mut [T],
remainders: &[T],
unit: T,
given: &[T],
) -> Option<()> {
if parts.len() != given.len() || !total.in_line_range() || !unit.in_line_range() {
return None;
}
if parts.iter().chain(given).any(|a| !a.in_line_range()) {
return None;
}
apportion(total, parts, remainders, unit)?;
// Each part is now under three line ranges and each `given` within
// one: the subtraction can't fail (Kani apportion_cumulative_is_exact).
for (part, &g) in parts.iter_mut().zip(given) {
*part = part.checked_sub(g)?;
}
Some(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -423,6 +480,51 @@ mod tests {
assert_eq!(apportion(0, &mut none, &[], -1), None);
}
/// apportion_cumulative scenario 1: mismatched lengths, a bad unit, an
/// input out of line range, or a shortfall apportion refuses: None,
/// and the parts untouched.
#[test]
fn apportion_cumulative_refuses_mismatched_or_out_of_range_inputs() {
let big = (1i64 << 40) + 1;
for (total, parts, rems, unit, given) in [
(4i64, [2i64, 2], [0i64, 0], 1i64, &[0i64][..]),
(4, [2, 2], [0, 0], 0, &[0, 0][..]),
(4, [2, 2], [0, 0], big, &[0, 0][..]),
(big, [2, 2], [0, 0], 1, &[0, 0][..]),
(4, [big, 2], [0, 0], 1, &[0, 0][..]),
(4, [2, 2], [0, 0], 1, &[big, 0][..]),
// Three units short of two parts: not rounding.
(7, [2, 2], [0, 0], 1, &[0, 0][..]),
] {
let mut p = parts;
assert_eq!(
apportion_cumulative(total, &mut p, &rems, unit, given),
None,
"{total} {parts:?} {unit} {given:?}"
);
assert_eq!(p, parts);
}
// Remainders that don't pair with the parts: apportion's refusal.
let mut p = [2i64, 2];
assert_eq!(apportion_cumulative(4, &mut p, &[0], 1, &[0, 0]), None);
assert_eq!(p, [2, 2]);
}
/// apportion_cumulative scenario 2 (cents, i64): three cents closed
/// one at a time gave A all three; the fourth cent's close apportions
/// the running 4 cents (2 / 2) and takes back what A and B had.
#[test]
fn apportion_cumulative_trues_up_a_legacy_split() {
let mut parts = [2i64, 2];
apportion_cumulative(4, &mut parts, &[0, 0], 1, &[3, 0]).unwrap();
assert_eq!(parts, [-1, 2]);
// A running total of 5: floors 2 / 2 with equal remainders, so the
// tied unit goes to the first (3 / 2), less what each had (3 / 1).
let mut parts = [2i64, 2];
apportion_cumulative(5, &mut parts, &[5, 5], 1, &[3, 1]).unwrap();
assert_eq!(parts, [0, 1]);
}
/// With no parts, only a zero whole is fully apportioned.
#[test]
fn apportion_to_no_parts_accepts_only_zero() {
+335 -42
View File
@@ -2127,6 +2127,7 @@ impl Ledger {
closing_entry: None,
close_seq: None,
member_close: None,
members_closed: None,
},
);
Ok(id)
@@ -2173,9 +2174,17 @@ impl Ledger {
/// - `RetainedEarnings`: account `3999`, created if missing.
/// - `Account`: one equity account (a sole proprietor's capital).
/// - `Members`: each member's capital account gets their share of
/// the net, to the cent (odd cents by largest remainder; closes
/// journaled before 2026-10-13 replay with the last member
/// absorbing them), and their own distribution account.
/// everything closed to these members and shares so far, to the
/// currency's minor unit (odd units by largest remainder, ties to
/// the member listed first), less what earlier closes gave them
/// (rules.md B-13; D-10), and their own distribution account. The
/// count starts at the first members close, or at the first since
/// the members or shares last changed (`Ledger::members_regime`);
/// closes to anything else are skipped. A close that posts an
/// entry may carry a one-time true-up of rounding by closes made
/// under an earlier rule (the journaled `MembersRun`,
/// `MembersPerClose` and `MembersLastAbsorbs`, which replay as
/// they were made).
/// 3. Marks the period closed.
///
/// Returns the closing entry's id, or `None` if there was nothing to
@@ -2191,12 +2200,20 @@ impl Ledger {
/// balance as of the period's last day.
/// 2. The closing entry MUST balance, be dated the period's last day, and
/// credit equity with exactly the net income through that day (split
/// among members by share, to the cent, by largest remainder).
/// among members by share, to the currency's minor unit, by largest
/// remainder, cumulatively).
/// 3. Entries dated after the period MUST stay untouched (their income
/// belongs to their own period).
/// 4. A sweep account (draws, distributions, dividends) MUST end at zero,
/// its balance moved into its target.
/// 5. NEVER post a closing entry when there is nothing to close.
/// 6. After a members close that posts, each member's total from the
/// count's closes MUST be one largest-remainder split of everything
/// they closed: under one minor unit from their exact share. A
/// members close that posts nothing credits no one (any true-up
/// waits for the next close that posts).
/// 7. NEVER changes a posted close: earlier closes replay under the
/// rule they were journaled with.
/// SCENARIOS:
/// 1. IF the period is unknown or already closed: SHOULD refuse.
/// 2. IF a draft is dated inside it: SHOULD refuse (DraftsInPeriod).
@@ -2205,6 +2222,13 @@ impl Ledger {
/// member shares aren't positive and summing to 1: SHOULD refuse.
/// 5. IF closing to retained earnings and 3999 exists but isn't a
/// postable equity account: SHOULD refuse (ControlAccountMismatch).
/// 6. IF a journaled MembersCumulative names a start other than the
/// one the books derive: SHOULD refuse (InvalidMembers)
/// BECAUSE replay would split differently from the journal.
/// 7. IF 50/50 members closed −399.23 in January (A −199.62, B
/// −199.61), February closed to retained earnings, and March closes
/// −399.23 to the members: SHOULD give A −199.61, B −199.62, so
/// each has −399.23 (the count skips February's close).
/// ```
pub fn close_period_to(
&mut self,
@@ -2223,11 +2247,13 @@ impl Ledger {
self.preview_close_period_to(id, to)?;
let period = self.periods.get(&id).expect("previewed").clone();
let (closings, net) = self.income_closings(period.end);
let close_seq = self.next_close_seq();
// (account, amount to credit; negative debits): the net to its
// targets, and each sweep account's balance into its target.
let mut equity: Vec<(AccountId, Decimal)> = Vec::new();
let mut sweep_lines: Vec<(AccountId, AccountId, Decimal)> = Vec::new();
let mut members_closed = None;
let mut sweep = |from: AccountId, into: AccountId, this: &Self| {
// An equity balance (credit-normal) is −movement; zeroing it
// moves exactly that much into the target.
@@ -2259,30 +2285,27 @@ impl Ledger {
sweep(*a, *into, self);
}
}
periods::CloseTo::Members(members)
| periods::CloseTo::MembersPerClose(members)
| periods::CloseTo::MembersLastAbsorbs(members) => {
let shares: Vec<Decimal> = members.iter().map(|m| m.share).collect();
let parts = match to {
periods::CloseTo::MembersLastAbsorbs(_) => {
periods::allocate_last_absorbs(net, &shares)
}
periods::CloseTo::MembersPerClose(_) => periods::allocate(net, &shares),
_ => {
// Cumulative: what the run gives each member with
// this close, less what it gave them before.
let before = self.member_close_run(&member_key(members));
let after = periods::allocate(before + net, &shares);
let prior = periods::allocate(before, &shares);
after.iter().zip(&prior).map(|(a, p)| a - p).collect()
}
};
for (m, part) in members.iter().zip(parts) {
equity.push((m.capital, part));
_ => {
let members = to.members().expect("every other target is members");
// The sweeps first: whether an entry posts decides whether
// the parts may carry a true-up. Their lines still follow
// the parts in the entry.
for m in members {
if let Some(d) = m.distributions {
sweep(d, m.capital, self);
}
}
let posts = !closings.is_empty() || !sweep_lines.is_empty();
let (parts, rule) = self.members_split(to, net, posts, close_seq)?;
for (m, part) in members.iter().zip(&parts) {
equity.push((m.capital, *part));
}
members_closed = Some(periods::MembersClosed {
members: member_key(members),
net,
parts,
rule,
});
}
}
@@ -2314,26 +2337,201 @@ impl Ledger {
Some(entry)
};
let close_seq = self
.periods
.values()
.filter(|p| p.status == PeriodStatus::Closed)
.count() as u64
+ 1;
let p = self.periods.get_mut(&id).expect("period exists");
p.status = PeriodStatus::Closed;
p.closing_entry = closing_entry;
p.close_seq = Some(close_seq);
p.member_close = match to {
periods::CloseTo::Members(members) => Some(periods::MemberCloseRecord {
periods::CloseTo::MembersRun(members) => Some(periods::MemberCloseRecord {
members: member_key(members),
net,
}),
_ => None,
};
p.members_closed = members_closed;
Ok(closing_entry)
}
/// The number the next close takes ([`Period::close_seq`]): one more
/// than the closes so far.
fn next_close_seq(&self) -> u64 {
self.periods
.values()
.filter(|p| p.status == PeriodStatus::Closed)
.count() as u64
+ 1
}
/// What a members close (`to`, closing `net`; `posts` when its entry
/// posts; numbered `close_seq`) credits each member, in `to`'s order,
/// and the rule it is made under: the rule journaled for replayed
/// closes, else the cumulative rule (rules.md B-13): each member's
/// split of the count's running total, to the base currency's minor
/// unit, less what the count's earlier closes gave them. A close that
/// posts nothing credits nothing, so a true-up of earlier rounding
/// rides only on an entry that posts (INVARIANT 5 stays true).
fn members_split(
&self,
to: &periods::CloseTo,
net: Decimal,
posts: bool,
close_seq: u64,
) -> Result<(Vec<Decimal>, periods::MemberRule), LedgerError> {
use periods::{CloseTo, MemberRule};
let members = to.members().expect("a members close");
let shares: Vec<Decimal> = members.iter().map(|m| m.share).collect();
Ok(match to {
CloseTo::MembersLastAbsorbs(_) => (
periods::allocate_last_absorbs(net, &shares),
MemberRule::LastAbsorbs,
),
CloseTo::MembersPerClose(_) => (periods::allocate(net, &shares), MemberRule::PerClose),
CloseTo::MembersRun(_) => {
// The run rule: what the run gives each member with this
// close, less what it gave them before.
let before = self.member_close_run(&member_key(members));
let after = periods::allocate(before + net, &shares);
let prior = periods::allocate(before, &shares);
let parts = after.iter().zip(&prior).map(|(a, p)| a - p).collect();
(parts, MemberRule::Run)
}
_ => {
let key = member_key(members);
let regime = self.members_regime(&key, None);
let since_seq = regime.since_seq.unwrap_or(close_seq);
let parts = if posts {
regime.allocate(
&key,
regime.closed + net,
&regime.given,
self.base_currency.allocation_places(),
)?
} else {
vec![Decimal::ZERO; members.len()]
};
(parts, MemberRule::Cumulative { since_seq })
}
})
}
/// The members regime a close with `members` ((capital account, share)
/// pairs) belongs to (rules.md B-13), counting only closes whose
/// period ends on or before `through` (all when `None`): walking the
/// closes newest first by [`Period::close_seq`], members closes under
/// any rule with the same SET of (capital, share) pairs (order and
/// distribution accounts ignored; 0.5 is 0.50), skipping closes to
/// anything else (they allocate nothing to members), and stopping at
/// the first members close with another set. A change of members or
/// shares starts a new count (26 U.S.C. §706(d)(1); Treas. Reg.
/// §1.706-4(a)(3)(vii): each segment its own distributive share
/// period); income closed under the old shares is never re-split.
///
/// The date filter keeps a prefix of the closes in close order because
/// period ends rise with close order: a close is refused while a later
/// period is closed (CloseOutOfOrder) or when its end lies in a closed
/// period (`preview_close_period_to`), pinned by
/// `period_ends_rise_with_close_order`.
///
/// As of a day no count with `members` holds yet, the earnings not
/// closed by then go to the count the next members close begins, when
/// it is these members': its first close's order is the regime's
/// [`Regime::order`], so their tied units go as they will once it
/// counts them, and a reorder of the members in the settings since
/// moves no cent on those days either.
pub(crate) fn members_regime(
&self,
members: &[(AccountId, Decimal)],
through: Option<NaiveDate>,
) -> Regime {
let mut regime = self.regime_where(members, |p| through.is_none_or(|d| p.end <= d));
if let Some(day) = through
&& regime.order.is_empty()
{
let next = self
.periods
.values()
.filter(|p| p.status == PeriodStatus::Closed && p.end > day)
.filter_map(|p| Some((p.close_seq, p.members_closed.as_ref()?)))
.min_by_key(|&(seq, _)| seq);
if let Some((_, m)) = next
&& same_member_set(&m.members, members)
{
regime.order.clone_from(&m.members);
}
}
regime
}
/// [`Self::members_regime`] over the closed periods `include` keeps.
fn regime_where(
&self,
members: &[(AccountId, Decimal)],
include: impl Fn(&Period) -> bool,
) -> Regime {
let mut closed: Vec<&Period> = self
.periods
.values()
.filter(|p| p.status == PeriodStatus::Closed && include(p))
.collect();
closed.sort_by_key(|p| std::cmp::Reverse(p.close_seq));
let mut regime = Regime {
since_seq: None,
closed: Decimal::ZERO,
given: vec![Decimal::ZERO; members.len()],
order: Vec::new(),
};
for p in closed {
let Some(m) = &p.members_closed else {
continue;
};
if !same_member_set(&m.members, members) {
break;
}
regime.since_seq = p.close_seq;
regime.order.clone_from(&m.members);
regime.closed += m.net;
for ((capital, _), part) in m.members.iter().zip(&m.parts) {
if let Some(i) = members.iter().position(|(c, _)| c == capital) {
regime.given[i] += *part;
}
}
}
regime
}
/// A closed members period's split under the cumulative rule
/// (books.close_period's `members`): the close its count starts at,
/// and per member, in the close's order, what this close credited
/// them, their running total from the count's closes through it, and
/// their exact share of everything the count closed through it. `None`
/// for a period not closed to members, or closed under an earlier
/// rule.
pub fn member_close_split(&self, id: PeriodId) -> Option<(u64, Vec<periods::MemberSplit>)> {
let p = self.periods.get(&id)?;
let m = p.members_closed.as_ref()?;
let periods::MemberRule::Cumulative { since_seq } = m.rule else {
return None;
};
let seq = p.close_seq?;
let regime = self.regime_where(&m.members, |q| q.close_seq <= Some(seq));
let split = m
.members
.iter()
.zip(&m.parts)
.zip(&regime.given)
.map(
|((&(capital, share), &part), &running)| periods::MemberSplit {
capital,
share,
part,
running,
exact: share * regime.closed,
},
)
.collect();
Some((since_seq, split))
}
/// The net closed by the run of cumulative members closes, with exactly
/// these members and shares, that ends with the latest close: zero if
/// the latest close was anything else (or there is none).
@@ -2408,9 +2606,13 @@ impl Ledger {
accounts.push(*into);
accounts.extend(sweep);
}
periods::CloseTo::Members(members)
| periods::CloseTo::MembersPerClose(members)
| periods::CloseTo::MembersLastAbsorbs(members) => {
_ => {
let members = to.members().expect("every other target is members");
if let periods::CloseTo::MembersCumulative { since_seq: 0, .. } = to {
return Err(LedgerError::InvalidMembers(
"a count of members closes starts at close 1 or later, not 0".into(),
));
}
let total: Decimal = members.iter().map(|m| m.share).sum();
if members.is_empty()
|| total != Decimal::ONE
@@ -2467,7 +2669,9 @@ impl Ledger {
/// [`Self::close_period_to`] would succeed — the period exists and is
/// open, no draft entries are dated inside it, no later period is
/// closed, the closing date isn't inside another closed period, and the
/// target is valid — and returns the id the closing entry will receive,
/// target is valid (for members: their split computes exactly, and a
/// journaled start is the one the books derive) — and returns the id
/// the closing entry will receive,
/// or `None` when the close posts no entry. Stores call this before
/// journaling so a failed close leaves no journal line behind.
pub fn preview_close_period_to(
@@ -2510,20 +2714,36 @@ impl Ledger {
// An entry posts exactly when there is income to close or a sweep
// account with a balance. Account creation (Retained Earnings) does
// not consume entry ids.
let (closings, _) = self.income_closings(period.end);
let (closings, net) = self.income_closings(period.end);
let sweeps: Vec<AccountId> = match to {
periods::CloseTo::RetainedEarnings { sweep }
| periods::CloseTo::Account { sweep, .. } => sweep.clone(),
periods::CloseTo::Members(members)
| periods::CloseTo::MembersPerClose(members)
| periods::CloseTo::MembersLastAbsorbs(members) => {
members.iter().filter_map(|m| m.distributions).collect()
}
_ => to
.members()
.expect("every other target is members")
.iter()
.filter_map(|m| m.distributions)
.collect(),
};
let any = !closings.is_empty()
|| sweeps
.iter()
.any(|a| !self.movement_through(*a, period.end).is_zero());
if to.members().is_some() {
// The journaled start must be the derived one, and the split
// must be computable: a close this refuses journals nothing.
let (_, rule) = self.members_split(to, net, any, self.next_close_seq())?;
if let (
periods::CloseTo::MembersCumulative { since_seq, .. },
periods::MemberRule::Cumulative { since_seq: derived },
) = (to, rule)
&& *since_seq != derived
{
return Err(LedgerError::InvalidMembers(format!(
"this close was journaled counting the members' closes from close {since_seq}, but the books count them from close {derived}"
)));
}
}
Ok(any.then_some(self.next_entry_id))
}
@@ -5267,7 +5487,80 @@ fn check_line_size(amount: Decimal) -> Result<(), LedgerError> {
}
/// A members close's (capital account, share) list, in order: what a run
/// of cumulative closes must share.
/// of run-rule closes must share in that order, and a count of cumulative
/// closes as a set ([`same_member_set`]).
fn member_key(members: &[periods::MemberClose]) -> Vec<(AccountId, Decimal)> {
members.iter().map(|m| (m.capital, m.share)).collect()
}
/// Two members closes' (capital account, share) lists name the same
/// members with the same shares, in any order (rules.md B-13: a reorder of
/// the members is not a change of members). Shares compare by value, so
/// 0.5 is 0.50.
fn same_member_set(a: &[(AccountId, Decimal)], b: &[(AccountId, Decimal)]) -> bool {
let sorted = |v: &[(AccountId, Decimal)]| {
let mut v = v.to_vec();
v.sort_unstable();
v
};
a.len() == b.len() && sorted(a) == sorted(b)
}
/// A members regime ([`Ledger::members_regime`]): the members closes with
/// one set of members and shares since the last close with another.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Regime {
/// The close the count starts at (its [`Period::close_seq`]); `None`
/// when no members close with this set is counted yet.
pub since_seq: Option<u64>,
/// The net those closes closed, together (negative for a loss).
pub closed: Decimal,
/// What those closes credited each member, in the order asked for, by
/// capital account.
pub given: Vec<Decimal>,
/// The members as the count's first close listed them: the order its
/// tied odd units go in. As of a day before any close of a count, the
/// count the next members close begins, when it is these members'
/// ([`Ledger::members_regime`]). Empty when there is none.
pub order: Vec<(AccountId, Decimal)>,
}
impl Regime {
/// [`periods::allocate_cumulative`] of `total` among `members` against
/// `given` (both in `members`' order), the tied odd units to the member
/// the count's first close listed first ([`Regime::order`]; `members`'
/// own order when no close has begun the count), the parts back in `members`'
/// order. So a count's ties go to the same member whatever order later
/// closes, statements or the settings list the members in, and a
/// reorder of the members moves no cent (rules.md B-13, D-38).
pub(crate) fn allocate(
&self,
members: &[(AccountId, Decimal)],
total: Decimal,
given: &[Decimal],
places: u32,
) -> Result<Vec<Decimal>, LedgerError> {
let order = if self.order.is_empty() {
members
} else {
&self.order
};
let index: Vec<usize> = order
.iter()
.map(|(capital, _)| {
members
.iter()
.position(|(c, _)| c == capital)
.expect("a count's members are the members asked for")
})
.collect();
let shares: Vec<Decimal> = index.iter().map(|&i| members[i].1).collect();
let given: Vec<Decimal> = index.iter().map(|&i| given[i]).collect();
let split = periods::allocate_cumulative(total, &shares, &given, places)?;
let mut parts = vec![Decimal::ZERO; members.len()];
for (&i, part) in index.iter().zip(split) {
parts[i] = part;
}
Ok(parts)
}
}
+2 -1
View File
@@ -69,7 +69,8 @@ pub use parties::{Party, PartyField, PartyId, Role, TinFacts, TinType, W4Facts,
pub use payouts::{Payout, PayoutAdjustment, PayoutLine, PayoutTotals, payout_plan};
pub use payroll::{PayrollLine, PayrollRun, PayrollRunId};
pub use periods::{
CloseTo, MemberClose, MemberCloseRecord, Period, PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE,
CloseTo, MemberClose, MemberCloseRecord, MemberRule, MemberSplit, MembersClosed, Period,
PeriodId, PeriodStatus, RETAINED_EARNINGS_CODE,
};
pub use reports::{
AssetRegister, AssetRegisterRow, BalanceSheet, BalanceSheetStatement, CashFlowLine,
+12
View File
@@ -57,6 +57,18 @@ impl Currency {
}
}
/// The decimal places a share of an amount in this currency is split
/// to (members' shares of earnings, periods::allocate_cumulative): its
/// minor units (cents, whole yen, thousandths of a dinar), or, for a
/// code ISO 4217 gives none (gold, SDRs), the most a line may carry
/// (kernel::MAX_LINE_SCALE). Every posted amount is then a whole number
/// of the unit split to, so no member's running share is ever a unit
/// from exact (rules.md B-13); split to cents, a finer amount's
/// leftover fraction would all go to one member.
pub fn allocation_places(&self) -> u32 {
self.minor_units().unwrap_or(crate::kernel::MAX_LINE_SCALE)
}
/// The three-letter code, e.g. `"USD"`.
pub fn code(&self) -> &str {
std::str::from_utf8(&self.0).expect("currency codes are ASCII by construction")
+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);
+170
View File
@@ -543,6 +543,176 @@ fn apportion_is_fair() {
);
}
/// `kernel::apportion_cumulative` is `kernel::apportion` less what was
/// given: it succeeds exactly when `apportion` would on the same inputs and
/// the running total is in line range (the parts, unit and `given` are,
/// here), and then each part is apportion's less its `given`, so each
/// running total (given + part) is apportion's. With `apportion_is_exact`,
/// the parts sum to the running total less what was given, and each
/// running total is nothing or one unit above its rounded-down part (one
/// of them may also take the sub-unit fraction). A refusal leaves the
/// parts as they were.
#[kani::proof]
#[kani::unwind(5)]
fn apportion_cumulative_is_exact() {
let (n, before, rems, unit, _, whole) = any_apportionment();
let given: [i64; AP] = core::array::from_fn(|_| any_amount());
let mut reference = before;
let apportioned = kernel::apportion(whole, &mut reference[..n], &rems[..n], unit);
let mut parts = before;
let ok = kernel::apportion_cumulative(whole, &mut parts[..n], &rems[..n], unit, &given[..n]);
assert_eq!(ok.is_some(), apportioned.is_some() && whole.in_line_range());
let mut against = false;
for i in 0..n {
if ok.is_some() {
assert_eq!(parts[i] + given[i], reference[i]);
against |= parts[i] < 0;
} else {
assert_eq!(parts[i], before[i]);
}
}
kani::cover!(
ok.is_some() && n == AP && against && whole > given[0] + given[1] + given[2],
"a true-up against the net's sign"
);
}
/// Bound on the amounts the drift harnesses explore (symbolic multiply and
/// divide are costly for the solver; Lean's `allocW_drift` and
/// `cumulative_drift` carry any weights and any amounts).
const DRIFT: i64 = 1 << 12;
/// **Bounded drift** (rules.md B-13) for members with these integer
/// weights (shares w_i / W, W their sum): a cumulative members close,
/// modelled as `periods::allocate_cumulative` runs it at whole minor units
/// (unit 1), hands out exactly its net, and leaves every member's running
/// total, given + part, within one unit of their exact share of
/// everything closed, whatever earlier closes gave them (any `given`
/// summing to what they closed: legacy rounding included). Scaled by W:
/// |(given_i + part_i)·W − T·w_i| < W.
fn drift_is_under_a_unit(weights: &[i64]) {
let n = weights.len();
let mut total_weight = 0i64;
for w in weights {
total_weight += w;
}
let closed: i64 = kani::any();
let net: i64 = kani::any();
kani::assume((-DRIFT..=DRIFT).contains(&closed) && (-DRIFT..=DRIFT).contains(&net));
// What earlier closes gave: anything, as long as it adds up.
let mut given = [0i64; AP];
let mut others = 0i64;
for g in given.iter_mut().take(n - 1) {
*g = kani::any();
kani::assume((-DRIFT..=DRIFT).contains(g));
others += *g;
}
given[n - 1] = closed - others;
// allocate_cumulative: |T| split by exact share, rounded toward zero,
// the remainders ranked; a loss runs on |T| against given negated.
let total = closed + net;
let magnitude = total.abs();
let loss = total < 0;
let mut parts = [0i64; AP];
let mut rems = [0i64; AP];
let mut against = [0i64; AP];
for i in 0..n {
parts[i] = magnitude * weights[i] / total_weight;
rems[i] = magnitude * weights[i] % total_weight;
against[i] = if loss { -given[i] } else { given[i] };
}
kernel::apportion_cumulative(magnitude, &mut parts[..n], &rems[..n], 1, &against[..n])
.expect("exact shares are fewer units short than there are members");
let mut sum = 0i64;
for i in 0..n {
if loss {
parts[i] = -parts[i];
}
sum += parts[i];
let drift = (given[i] + parts[i]) * total_weight - total * weights[i];
assert!(-total_weight < drift && drift < total_weight);
}
assert_eq!(sum, net);
kani::cover!(
loss && given[0] * total_weight != closed * weights[0],
"a loss, after an earlier split off the exact share"
);
}
/// [`drift_is_under_a_unit`] for two members at half each: every odd cent
/// a tie.
#[kani::proof]
#[kani::unwind(4)]
fn cumulative_close_never_drifts_a_unit_at_halves() {
drift_is_under_a_unit(&[1, 1]);
}
/// [`drift_is_under_a_unit`] for three members at 20% / 30% / 50%:
/// remainders that differ.
#[kani::proof]
#[kani::unwind(4)]
fn cumulative_close_never_drifts_a_unit_at_tenths() {
drift_is_under_a_unit(&[2, 3, 5]);
}
/// [`drift_is_under_a_unit`] for three members at 33.33% / 33.33% /
/// 33.34%, the nearest decimal shares to a third each.
#[kani::proof]
#[kani::unwind(4)]
fn cumulative_close_never_drifts_a_unit_at_near_thirds() {
drift_is_under_a_unit(&[3333, 3333, 3334]);
}
/// A cumulative members close balances: the nominal accounts' closing
/// lines and each member's part of the running split (any earlier split
/// of what was closed before) net to zero, whatever the true-up.
#[kani::proof]
#[kani::unwind(6)]
fn cumulative_closing_entry_balances() {
const NOMINAL: usize = 3;
let nominal: [i64; NOMINAL] = core::array::from_fn(|_| {
let a = any_amount();
kani::assume((-(1 << 38)..=1 << 38).contains(&a));
a
});
let mut entry = [(Side::Debit, 0i64); NOMINAL + 2];
let mut len = 0;
for &m in &nominal {
if let Some(line) = kernel::closing_line(m) {
entry[len] = line;
len += 1;
}
}
let net = kernel::net(entry[..len].iter().copied()).expect("no overflow");
// Two members at half each; earlier closes gave them any split of
// what they closed.
let closed: i64 = kani::any();
kani::assume((-(1 << 38)..=1 << 38).contains(&closed));
let first = any_amount();
let given = [first, closed - first];
let total = closed + net;
let (magnitude, loss) = (total.abs(), total < 0);
let mut parts = [magnitude / 2, magnitude / 2];
let rems = [magnitude % 2, magnitude % 2];
let against = if loss { [-given[0], -given[1]] } else { given };
if kernel::apportion_cumulative(magnitude, &mut parts, &rems, 1, &against).is_none() {
// Only an earlier split out of line range is refused here.
assert!(!given[1].in_line_range());
return;
}
let mut true_up = false;
for p in parts {
let p = if loss { -p } else { p };
if let Some(line) = kernel::credit_line(p) {
entry[len] = line;
len += 1;
true_up |= net != 0 && (p > 0) != (net > 0);
}
}
assert_eq!(wide_net(&entry[..len]), 0);
kani::cover!(true_up, "a true-up line on a member");
}
// ---------------------------------------------------------------------------
// The checker itself
// ---------------------------------------------------------------------------
+65 -27
View File
@@ -4,7 +4,6 @@
//! (`docs/oracle.md`) diffs: the same fixture books through two engines must
//! produce byte-identical [`snapshot`] output.
use crate::periods::allocate;
use crate::{AccountType, Class, ClassId, JournalEntry, JournalLine, Ledger, LedgerError};
use chrono::NaiveDate;
use rust_decimal::Decimal;
@@ -842,32 +841,45 @@ pub fn cancelled_in_span(
/// distributions (debits). Earnings are partnership earnings: net income
/// for the period, and earnings from before it not yet distributed (retained
/// earnings plus unclosed income), are allocated by share. Allocation is to
/// the cent, odd cents by largest remainder, so every row's total is exact
/// and the ending total equals balance-sheet equity.
/// the currency's minor unit, odd units by largest remainder, the same
/// running way the members closes split them, so every row's total is
/// exact and the ending total equals balance-sheet equity.
///
/// ```text
/// GOAL: each member's capital roll-forward for a partnership over a
/// period: beginning, contributions, allocated net income, distributions,
/// ending; with a total.
/// SOURCE: docs/spec/rules.md B-13 (26 U.S.C. §704(a); Form 1065 Schedule
/// M-2), B-11; R-9c.
/// SOURCE: docs/spec/rules.md B-13 (26 U.S.C. §704(a); Treas. Reg.
/// §1.704-1(b)(2)(iv)(b); Form 1065 Schedule M-2; D-38), B-11;
/// R-9c.
/// INVARIANTS:
/// 1. Each row MUST roll forward exactly: beginning + contributions +
/// income - distributions + other = ending.
/// 2. The total ending MUST equal balance-sheet equity at `end`.
/// 3. Undistributed earnings MUST be split by share, to the cent, by
/// largest remainder (DECISION, owner 2026-10-13), cumulatively as
/// of each date: a member's beginning is their capital and share as
/// of the day before `start`, their ending as of `end`.
/// 3. Undistributed earnings U MUST be split by share, to the base
/// currency's minor unit, by largest remainder (DECISION, owner
/// 2026-10-13), as of each date d, the way the next members close
/// would split them: allocate(C + U) − allocate(C), where C is what
/// the members regime's closes dated on or before d closed
/// (Ledger::members_regime; D-38, P3 review R-1). A member's
/// beginning is their capital and share as of the day before
/// `start`, their ending as of `end`. Tied units, "other"'s
/// included, go to the member the count's first close listed first
/// (Regime::allocate; on a day before it, the count the next members
/// close begins), so the order `members` lists them in moves no unit
/// once a close has begun the count.
/// 3a. A member's ending on one statement MUST equal their beginning on
/// the next, exactly (a capital account is one running balance: Form
/// 1065 Schedule K-1 instructions, Item L, "beginning capital account
/// and ending capital account ... based on the partnership's books").
/// the next, exactly (a capital account is one running balance of
/// contributions, allocations and distributions: Treas. Reg.
/// §1.704-1(b)(2)(iv)(b)).
/// 3b. A member's income is the roll-forward's remainder: their share of
/// the period's income, off by less than two cents (under one cent of
/// rounding at each end of the cumulative split); the members'
/// incomes MUST sum to the period's net income. (Earnings a close has
/// already moved into capital were rounded by that close, B-13.)
/// the period's income, off by less than two minor units (under one
/// at each end of the cumulative split); the members' incomes MUST
/// sum to the period's net income.
/// 3c. A member's capital as of a date MUST NOT change when a period
/// ending on or before it is later closed to the members, except by a
/// true-up of rounding by closes made under an earlier rule, which
/// shows only once a close posts it (D-38; R-1).
/// 4. NEVER hide an unexplained change: it goes in "other".
/// 5. An entry dated `start` is period activity, not beginning capital.
/// 6. An entry and its reversal both dated in the span cancel
@@ -981,8 +993,8 @@ pub fn members_equity(
// earnings not yet distributed. Each member's share of it is allocated
// cumulatively, as of a date, so a member's capital on a date never
// depends on which statement shows it: one statement's ending is the
// next one's beginning, to the cent (a capital account is one running
// balance: Form 1065 Schedule K-1 instructions, Item L).
// next one's beginning, to the minor unit (a capital account is one
// running balance: Treas. Reg. §1.704-1(b)(2)(iv)(b)).
let capital_through = |id: crate::AccountId, through: NaiveDate| -> Decimal {
let mut m = Decimal::ZERO;
for e in ledger.posted_entries().filter(|e| e.date() <= through) {
@@ -1003,19 +1015,45 @@ pub fn members_equity(
.map_or(Decimal::ZERO, |d| capital_through(d, end))
})
.collect();
let begin_alloc = allocate(
// As of a date, a member's share of the earnings not yet in capital is
// what the next members close would give them of it: their split of
// everything closed to these members and shares (the regime's closes
// through that date) plus it, less their split of the closed part
// alone. Closing a period moves earnings from here into capital
// without changing the sum, so a member's capital on a date is the
// same before and after its period closes (D-38); a true-up of
// rounding by closes made under an earlier rule shows only once a
// close posts it (P3 review R-1).
let places = ledger.base_currency().allocation_places();
let key: Vec<(crate::AccountId, Decimal)> = members
.iter()
.map(|m| (m.capital_account, m.share))
.collect();
let nothing = vec![Decimal::ZERO; members.len()];
let unclosed_share = |undistributed: Decimal, regime: &crate::ledger::Regime| {
let closed = regime.allocate(&key, regime.closed, &nothing, places)?;
regime.allocate(&key, regime.closed + undistributed, &closed, places)
};
let end_regime = ledger.members_regime(&key, Some(end));
let begin_alloc = unclosed_share(
begin_bs.total_equity - capital.iter().sum::<Decimal>(),
&shares,
);
let end_alloc = allocate(
&ledger.members_regime(&key, Some(day_before)),
)?;
let end_alloc = unclosed_share(
end_bs.total_equity - capital_end.iter().sum::<Decimal>(),
&shares,
);
&end_regime,
)?;
let explained = flow.iter().map(|(c, d)| c - d).sum::<Decimal>() + pnl.net_income();
let other_alloc = allocate(
// "Other" is split anew on each statement, its tied unit to the member
// the count as of `end` lists first, as the ending's are: a reorder of
// the members in the settings moves no cent between their other (and
// so their income) either.
let other_alloc = end_regime.allocate(
&key,
end_bs.total_equity - begin_bs.total_equity - explained,
&shares,
);
&nothing,
places,
)?;
let mut cols = Vec::with_capacity(members.len());
for (i, m) in members.iter().enumerate() {