fix(k1 tax basis): odd cents of property basis run cumulatively (B-36, D-38)
check / check (push) Canceled after 0s

The tax-basis K-1 shared each entry's built-in gain, property without a
basis, prepaid at book and property distributed among the members entry
by entry, every tied odd cent to the first member: three equal odd-cent
properties contributed 50/50 left the members three cents apart.

Apply D-38's rule (the closes' cumulative largest remainder, through
kernel::apportion_cumulative): tax_capital::RunningSplit keeps each
member's exact running share, rounds the running shares afresh to the
cent and hands out the change, so no member's running share is ever a
cent from exact and tied cents alternate. A negative running whole is
rounded as its magnitude, a zero one by the sign of the whole just
split, so a run of losses mirrors its gains part for part. Exact running
shares are taken to ten places before rounding: a sum of Decimal thirds
can sit a hair under its cent (the law found 399.93 as 399.9299...).

tax_capital::entry_parts runs the splits over every posted entry from
the books' first, in date order, whatever span is asked for, with one
count per set of members with a part in the entry: a member never takes
a cent of an entry they had no part in (Item L's capital contributed is
the basis of the property that member contributed), and the count never
restarts, since its weights are each entry's own amounts, not shares.
An entry's reversal takes its original's split back out of the count
(RunningSplit::unsplit), so an entry's cents are the same in every span
and next year's beginning is this year's ending. The K-1's per-entry
lines come from k1::entry_lines, which k1_detail now uses too.

The book property each member contributed in an entry stays split entry
by entry (each member's property must stay within their own credit);
B-36 says so.

Tests: tied_odd_cents_of_property_basis_run_cumulatively (fails on the
per-entry split: 665.86 / 665.83); a_member_outside_an_entry_never_
takes_its_cents (three members; fails with one count for all members),
an_entrys_odd_cent_is_the_same_in_every_span (a reversal across the
span's start; fails with the count carried span by span or the reversal
not taken out), the_split_runs_in_date_order (fails in posting order);
the law a_running_split_never_drifts_a_cent (5,000 cases: the
largest-remainder rounding of exact integer shares, ties to the first,
and the negated run mirrored; fails with ties to the later member or a
zero running whole rounded as a gain); unit tests for one split,
alternating ties, a member outside a split, the mirror at zero, unsplit
and the thirds case; bookspec example partnership-k1-tax-basis-odd-cents.
rules.md B-36, the handbook (tracing figures), STATUS, testing.md and
verification.md updated.

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-22 19:31:18 -06:00
1 parent 535874e0f3
commit d64e0e9cdf
11 files changed
+1287 -145

No files matched your search

+70 -46
View File
@@ -171,67 +171,29 @@ pub fn k1_detail(
let places = ledger.base_currency().allocation_places();
let closings = ledger.closing_entry_ids();
let cancelled = crate::reports::cancelled_in_span(ledger, start, end);
let property_account = |id: AccountId| is_property_account(ledger, cash, id);
let sum = |e: &crate::JournalEntry, side: Side, account: AccountId| -> Decimal {
e.lines()
.iter()
.filter(|l| l.side == side && l.account == account)
.map(|l| l.amount.amount)
.sum()
};
let mut lines = Vec::new();
for e in ledger
.posted_entries()
.filter(|e| e.date() >= start && e.date() <= end && !cancelled.contains(&e.id()))
{
let line = |member, kind, amount| K1Line {
member,
entry: e.id(),
kind,
amount,
};
if closings.contains(&e.id()) {
for (i, m) in members.iter().enumerate() {
let effect: Decimal = std::iter::once(m.capital_account)
.chain(m.distribution_account)
.map(|a| sum(e, Side::Credit, a) - sum(e, Side::Debit, a))
.map(|a| side_sum(e, Side::Credit, a) - side_sum(e, Side::Debit, a))
.sum();
if !effect.is_zero() {
lines.push(line(i, K1Kind::IncomeClose, effect));
lines.push(K1Line {
member: i,
entry: e.id(),
kind: K1Kind::IncomeClose,
amount: effect,
});
}
}
continue;
}
let pool: Decimal = e
.lines()
.iter()
.filter(|l| l.side == Side::Debit && property_account(l.account))
.map(|l| l.amount.amount)
.sum();
// Contributions are credits to capital (members_equity's).
let credits: Vec<Decimal> = members
.iter()
.map(|m| sum(e, Side::Credit, m.capital_account))
.collect();
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() {
lines.push(line(i, K1Kind::CashContribution, credit - take));
}
if !take.is_zero() {
lines.push(line(i, K1Kind::PropertyContribution, take));
}
// Distributions: debits to capital, net debits to the
// distributions account (members_equity's).
let taken = sum(e, Side::Debit, m.capital_account)
+ m.distribution_account.map_or(Decimal::ZERO, |d| {
sum(e, Side::Debit, d) - sum(e, Side::Credit, d)
});
if !taken.is_zero() {
lines.push(line(i, K1Kind::Distribution, -taken));
}
}
lines.extend(entry_lines(ledger, e, members, cash, places)?);
}
let total = |i: usize, k: K1Kind| -> Decimal {
lines
@@ -266,6 +228,68 @@ pub fn k1_detail(
Ok(K1Detail { rows, lines })
}
/// Σ of an entry's lines on `account`, on `side`.
fn side_sum(e: &crate::JournalEntry, side: Side, account: AccountId) -> Decimal {
e.lines()
.iter()
.filter(|l| l.side == side && l.account == account)
.map(|l| l.amount.amount)
.sum()
}
/// One entry's contribution and distribution lines, as [`k1_detail`]
/// gives them for an entry that is not a period's closing entry
/// (INVARIANTS 3-5): they depend on the entry alone, never on the span,
/// so the tax-basis worksheet (crate::tax_capital) reads every entry's
/// the same way whatever span it is asked for. `places` is the base
/// currency's Currency::allocation_places.
pub(crate) fn entry_lines(
ledger: &Ledger,
e: &crate::JournalEntry,
members: &[MemberCapital],
cash: &[AccountId],
places: u32,
) -> Result<Vec<K1Line>, LedgerError> {
let line = |member, kind, amount| K1Line {
member,
entry: e.id(),
kind,
amount,
};
let pool: Decimal = e
.lines()
.iter()
.filter(|l| l.side == Side::Debit && is_property_account(ledger, cash, l.account))
.map(|l| l.amount.amount)
.sum();
// Contributions are credits to capital (members_equity's).
let credits: Vec<Decimal> = members
.iter()
.map(|m| side_sum(e, Side::Credit, m.capital_account))
.collect();
let takes = share_property(pool, &credits, places)?;
let mut lines = Vec::new();
for (i, m) in members.iter().enumerate() {
let (credit, take) = (credits[i], takes[i]);
if !(credit - take).is_zero() {
lines.push(line(i, K1Kind::CashContribution, credit - take));
}
if !take.is_zero() {
lines.push(line(i, K1Kind::PropertyContribution, take));
}
// Distributions: debits to capital, net debits to the
// distributions account (members_equity's).
let taken = side_sum(e, Side::Debit, m.capital_account)
+ m.distribution_account.map_or(Decimal::ZERO, |d| {
side_sum(e, Side::Debit, d) - side_sum(e, Side::Credit, d)
});
if !taken.is_zero() {
lines.push(line(i, K1Kind::Distribution, -taken));
}
}
Ok(lines)
}
/// Whether an account holds property (INVARIANT 3): an asset account that
/// is neither cash (`cash`) nor receivables. The tax-basis capital
/// worksheet (crate::tax_capital) reads property the same way.
+524 -88
View File
@@ -23,10 +23,19 @@
//! basis. Within an entry, the property's built-in gain (its value at
//! contribution less its §723 basis, Asset::basis_figures) and the
//! book amount of property without a basis in the books are shared
//! among the members as its book value is (by their property in it,
//! to the cent, odd cents by largest remainder, ties to the earlier
//! member), so a member's tax property = book property − their share
//! of the built-in gain − their share of the property without a basis.
//! among the members as its book value is (by their property in it),
//! to the cent by a running split from the books' first entry
//! ([`RunningSplit`], D-38's rule: each member's running share is
//! the largest-remainder rounding of their exact running share, so
//! it is never a cent from it and tied odd cents alternate rather
//! than all going to the earlier member), so a member's tax property
//! = book property − their share of the built-in gain − their share
//! of the property without a basis. Property distributed is shared
//! among the members by what each took, the same running way. The
//! running split is kept per set of members with a part in the entry,
//! in date order whatever the span (`entry_parts`): a member never
//! takes a cent of an entry they had no part in, and an entry's cents
//! are the same in every span it is in.
//! 3. A figure the books can't know is never guessed: its line carries
//! `known`, the part the books have, and `open`, each missing item
//! with the book amount it replaces. A line with no open item is the
@@ -314,53 +323,393 @@ pub fn tax_basis_capital(
cash: &[AccountId],
) -> Result<TaxCapital, LedgerError> {
// Beginning: the same worksheet from the books' first entry to the day
// before, whose ending is this span's beginning.
// before, whose ending is this span's beginning. The odd cents of
// every entry through `end`, whatever the span (INVARIANT 2).
let parts = entry_parts(ledger, end, members, cash)?;
let first = ledger.posted_entries().map(|e| e.date()).min();
let prior = match (first, start.pred_opt()) {
(Some(first), Some(before)) if first <= before => {
Some(span(ledger, first, before, members, cash, None)?)
Some(span(ledger, first, before, members, cash, None, &parts)?)
}
_ => None,
};
span(ledger, start, end, members, cash, prior.as_ref())
span(ledger, start, end, members, cash, prior.as_ref(), &parts)
}
fn too_large() -> LedgerError {
LedgerError::TooLargeToCompute("a member's tax-basis capital".into())
}
/// `whole` shared in proportion to `weights` (non-negative), to the cent:
/// each part rounded toward zero, leftover cents by largest remainder,
/// ties to the earlier part; a negative whole is shared as its magnitude
/// and negated. All zero when the weights sum to zero.
fn share(whole: Decimal, weights: &[Decimal]) -> Result<Vec<Decimal>, LedgerError> {
use rust_decimal::RoundingStrategy::ToZero;
let total = weights
.iter()
.try_fold(Decimal::ZERO, |t, w| t.checked_add(*w))
.ok_or_else(too_large)?;
if whole.is_zero() || total <= Decimal::ZERO {
return Ok(vec![Decimal::ZERO; weights.len()]);
}
let magnitude = whole.abs();
let exact: Vec<Decimal> = weights
.iter()
.map(|w| magnitude.checked_mul(*w).and_then(|x| x.checked_div(total)))
.collect::<Option<_>>()
.ok_or_else(too_large)?;
let mut parts: Vec<Decimal> = exact
.iter()
.map(|x| x.round_dp_with_strategy(2, ToZero))
.collect();
let remainders: Vec<Decimal> = exact.iter().zip(&parts).map(|(x, p)| x - p).collect();
crate::kernel::apportion(magnitude, &mut parts, &remainders, Decimal::new(1, 2))
.ok_or_else(too_large)?;
if whole < Decimal::ZERO {
for p in &mut parts {
*p = -*p;
/// A running split of amounts among members, one split at a time, each
/// by its own weights (B-36, the odd cent as D-38 settles it): each
/// member's running total is rounded afresh from their exact running
/// total, so it never drifts a cent from it, and tied cents don't pile up
/// on one member. The worksheet keeps one per set of members with weight
/// (`entry_parts`), so in its use every member of a split has weight.
///
/// ```text
/// GOAL: no member's share of the property's built-in gain, of the
/// property without a basis, of the prepaid at book or of property
/// distributed is a cent or more from their exact share, however
/// many entries it is summed over; three equal odd-cent properties
/// contributed 50/50 leave the members one cent apart, never three.
/// SOURCE: rules.md B-36 (shared within each entry as its book value is),
/// B-13 (cent rounding by largest remainder: DECISION, owner
/// 2026-10-13); DECISION D-38 (owner, 2026-10-20: odd cents
/// everywhere alike, cumulative largest remainder), here for the
/// tax basis by the owner's ask, 2026-10-22 (kernel::
/// apportion_cumulative, the members closes' rule).
/// INVARIANTS:
/// 1. The parts of each split sum to its whole exactly.
/// 2. A member's exact running total is Σ whole × weight / Σ weights
/// over every split so far; their running total (Σ of their parts)
/// is the largest-remainder rounding to the cent of the exact running
/// totals (ties to the earlier member), so it is within a cent of
/// exact, strictly. A negative running whole is rounded as its
/// magnitude and negated; a running whole of zero is rounded by the
/// sign of the whole just split. So a run of losses mirrors the run
/// of gains with every whole negated, part for part.
/// 3. A split can move a member who has no weight in it by a cent: the
/// true-up that keeps their running total one rounding. The
/// worksheet never gives one such a split: it keeps a running split
/// per set of members with weight.
/// 4. The exact shares are Decimal quotients (28 significant digits),
/// so a running total of thirds can come out a hair under the cent
/// it truly is (399.93 as 399.9299…9): each exact running total is
/// taken to EXACT_PLACES (10) decimal places before it is rounded to
/// the cent, far finer than any remainder that decides a cent and far
/// coarser than the quotients' error. Should the floors still come
/// out a cent per member short, refuse (TooLargeToCompute), never
/// guess.
/// 5. unsplit takes a split back out (an entry's reversal): the running
/// wholes and exact totals are as before it, and each member's
/// running total is less exactly the part that split gave them.
/// SCENARIOS:
/// 1. IF −0.01 is split 50/50 three times:
/// - SHOULD give −0.01 / 0.00, then 0.00 / −0.01, then −0.01 / 0.00:
/// running −0.02 / −0.01
/// BECAUSE the exact running totals are −0.005, −0.01, −0.015 each.
/// 2. IF the weights sum to zero, or the whole is zero:
/// - SHOULD give zeros, the running totals as they were.
/// 3. IF 0.01 is split 1:1, then −0.01 1:0 (running 0.00, exact
/// −0.005 / 0.005):
/// - SHOULD give 0.01 / 0.00, then −0.02 / 0.01 (running −0.01 /
/// 0.01, rounded as a loss), and the run with each whole negated
/// −0.01 / 0.00, then 0.02 / −0.01
/// BECAUSE a loss mirrors a gain (INVARIANT 2); the member with no
/// weight moves (INVARIANT 3), which the worksheet never asks.
/// ```
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RunningSplit {
/// Each member's exact running total.
exact: Vec<Decimal>,
/// Each member's running total, to the cent.
given: Vec<Decimal>,
/// Σ wholes split so far.
total: Decimal,
}
impl RunningSplit {
/// A split among `n` members, nothing split yet.
pub fn new(n: usize) -> Self {
Self {
exact: vec![Decimal::ZERO; n],
given: vec![Decimal::ZERO; n],
total: Decimal::ZERO,
}
}
Ok(parts)
/// Each member's running total, to the cent (INVARIANT 2).
pub fn running(&self) -> &[Decimal] {
&self.given
}
/// Σ weights, or None when they are all zero (nothing to split by).
fn weight_sum(&self, weights: &[Decimal]) -> Result<Option<Decimal>, LedgerError> {
let n = self.given.len();
if weights.len() != n {
return Err(LedgerError::InvalidMembers(format!(
"{} weights for {n} members",
weights.len()
)));
}
let sum = weights
.iter()
.try_fold(Decimal::ZERO, |t, w| t.checked_add(*w))
.ok_or_else(too_large)?;
Ok((sum > Decimal::ZERO).then_some(sum))
}
/// `whole × weight / sum`, the exact share of one member.
fn share(whole: Decimal, weight: Decimal, sum: Decimal) -> Result<Decimal, LedgerError> {
whole
.checked_mul(weight)
.and_then(|p| p.checked_div(sum))
.ok_or_else(too_large)
}
/// Split `whole` (to the cent) by `weights` (non-negative, one per
/// member): each member's part, so that their running total is the
/// rounding of their exact running total (INVARIANTS 1-4).
pub fn split(
&mut self,
whole: Decimal,
weights: &[Decimal],
) -> Result<Vec<Decimal>, LedgerError> {
use rust_decimal::RoundingStrategy::ToNegativeInfinity;
let n = self.given.len();
let Some(sum) = self.weight_sum(weights)? else {
return Ok(vec![Decimal::ZERO; n]);
};
if whole.is_zero() {
return Ok(vec![Decimal::ZERO; n]);
}
let mut exact = self.exact.clone();
for (x, w) in exact.iter_mut().zip(weights) {
*x = x
.checked_add(Self::share(whole, *w, sum)?)
.ok_or_else(too_large)?;
}
let total = self.total.checked_add(whole).ok_or_else(too_large)?;
// A loss is rounded as its magnitude, then negated; at a running
// whole of zero, by the sign of this whole (INVARIANT 2).
let sign = if total < Decimal::ZERO || (total.is_zero() && whole < Decimal::ZERO) {
Decimal::NEGATIVE_ONE
} else {
Decimal::ONE
};
// Each exact running total to EXACT_PLACES (INVARIANT 4), then
// rounded down to the cent, what rounding took its remainder.
let snapped: Vec<Decimal> = exact
.iter()
.map(|x| (x * sign).round_dp(EXACT_PLACES))
.collect();
let mut parts: Vec<Decimal> = snapped
.iter()
.map(|x| x.round_dp_with_strategy(2, ToNegativeInfinity))
.collect();
let remainders: Vec<Decimal> = snapped.iter().zip(&parts).map(|(x, p)| x - p).collect();
let given: Vec<Decimal> = self.given.iter().map(|g| g * sign).collect();
crate::kernel::apportion_cumulative(
total * sign,
&mut parts,
&remainders,
Decimal::new(1, 2),
&given,
)
.ok_or_else(too_large)?;
for (p, g) in parts.iter_mut().zip(self.given.iter_mut()) {
*p *= sign;
*g = g.checked_add(*p).ok_or_else(too_large)?;
}
self.exact = exact;
self.total = total;
Ok(parts)
}
/// Take back out a split [`Self::split`] gave `parts` for, from the
/// same `whole` and `weights` (INVARIANT 5): what an entry's reversal
/// does to the running split, so the pair leaves the later splits as
/// they would be without it.
pub fn unsplit(
&mut self,
whole: Decimal,
weights: &[Decimal],
parts: &[Decimal],
) -> Result<(), LedgerError> {
let n = self.given.len();
let Some(sum) = self.weight_sum(weights)? else {
return Ok(());
};
if whole.is_zero() {
return Ok(());
}
if parts.len() != n {
return Err(LedgerError::InvalidMembers(format!(
"{} parts for {n} members",
parts.len()
)));
}
let mut exact = self.exact.clone();
let mut given = self.given.clone();
for i in 0..n {
exact[i] = exact[i]
.checked_sub(Self::share(whole, weights[i], sum)?)
.ok_or_else(too_large)?;
given[i] = given[i].checked_sub(parts[i]).ok_or_else(too_large)?;
}
self.total = self.total.checked_sub(whole).ok_or_else(too_large)?;
self.exact = exact;
self.given = given;
Ok(())
}
}
/// The decimal places a member's exact running total is taken to before
/// it is rounded to the cent ([`RunningSplit`] INVARIANT 4).
const EXACT_PLACES: u32 = 10;
/// The four amounts an entry's members share (B-36): the property's
/// built-in gain, its property without a basis, its prepaid at book (each
/// by the members' property in it) and the property it distributes (by
/// what each took).
const BUILT_IN_GAIN: usize = 0;
const WITHOUT_BASIS: usize = 1;
const PREPAID: usize = 2;
const DISTRIBUTED: usize = 3;
/// One entry's parts of the four amounts, per member; an empty list is
/// zeros.
#[derive(Clone, Debug, Default)]
struct EntryParts([Vec<Decimal>; 4]);
impl EntryParts {
fn get(&self, amount: usize, member: usize) -> Decimal {
self.0[amount].get(member).copied().unwrap_or(Decimal::ZERO)
}
}
/// One amount an entry splits: which, the whole, each member's weight.
type Whole = (usize, Decimal, Vec<Decimal>);
/// What one split put into a running split, to take it back out: the
/// amount, the members with weight, the whole, their weights and parts.
type Taken = (usize, Vec<usize>, Decimal, Vec<Decimal>, Vec<Decimal>);
/// The amounts `e` splits ([`Whole`]), from its K-1 lines.
fn entry_wholes(
ledger: &Ledger,
e: &crate::JournalEntry,
lines: &[crate::k1::K1Line],
n: usize,
cash: &[AccountId],
) -> Result<Vec<Whole>, LedgerError> {
let by_member = |kind| -> Vec<Decimal> {
(0..n)
.map(|i| {
lines
.iter()
.filter(|l| l.member == i && l.kind == kind)
.map(|l| l.amount)
.sum()
})
.collect()
};
let mut out = Vec::new();
let takes = by_member(K1Kind::PropertyContribution);
if takes.iter().any(|t| !t.is_zero()) {
let p = entry_property(ledger, e, cash)?;
out.push((BUILT_IN_GAIN, p.built_in_gain, takes.clone()));
out.push((WITHOUT_BASIS, p.without_basis, takes.clone()));
out.push((PREPAID, p.prepaid, takes));
}
// Distributions are negative lines; weights are what each took.
let taken: Vec<Decimal> = by_member(K1Kind::Distribution)
.into_iter()
.map(|d| -d)
.collect();
if taken.iter().any(|t| *t > Decimal::ZERO) {
let property = e
.lines()
.iter()
.filter(|l| {
l.side == Side::Credit && crate::k1::is_property_account(ledger, cash, l.account)
})
.try_fold(Decimal::ZERO, |t, l| t.checked_add(l.amount.amount))
.ok_or_else(too_large)?;
let total_taken = taken
.iter()
.try_fold(Decimal::ZERO, |t, x| t.checked_add(*x))
.ok_or_else(too_large)?;
let property = property.min(total_taken);
if property > Decimal::ZERO {
out.push((DISTRIBUTED, property, taken));
}
}
Ok(out)
}
/// Every entry's parts of the amounts it splits (B-36 with D-38), from
/// the books' first entry through `end`, whatever span the worksheet is
/// asked for, so an entry's cents are the same in every span it is in.
///
/// ```text
/// GOAL: odd cents that run (RunningSplit) and never touch a member who
/// has no part in the entry, nor change with the span asked for.
/// SOURCE: rules.md B-36; DECISION D-38; Form 1065 Item L (a partner's
/// capital contributed is the basis of the property that partner
/// contributed).
/// INVARIANTS:
/// 1. The entries are taken in date order, ties in posting order,
/// every posted entry through `end` but the periods' closing
/// entries, with no regard to the span: the reversed ones too.
/// 2. One running split per amount and per set of members with weight
/// in the entry (members with property in it, or who took): a split
/// never moves a member outside its set (RunningSplit INVARIANT 3
/// never arises), and a set's count never restarts, since its
/// weights are each entry's own amounts, not the members' shares.
/// 3. An entry that reverses one already split takes that split back
/// out of its count (RunningSplit::unsplit), each member exactly
/// their part: the pair leaves every later entry's cents as they
/// would be without it. Its own parts (it reads as the contrary
/// kind, a distribution for a contribution) are split on its own,
/// largest remainder, ties to the earlier member.
/// ```
fn entry_parts(
ledger: &Ledger,
end: NaiveDate,
members: &[MemberCapital],
cash: &[AccountId],
) -> Result<std::collections::BTreeMap<EntryId, EntryParts>, LedgerError> {
use std::collections::BTreeMap;
let n = members.len();
let places = ledger.base_currency().allocation_places();
let closings = ledger.closing_entry_ids();
let mut walk: Vec<&crate::JournalEntry> = ledger
.posted_entries()
.filter(|e| e.date() <= end && !closings.contains(&e.id()))
.collect();
walk.sort_by_key(|e| e.date());
let mut counts: BTreeMap<(usize, Vec<usize>), RunningSplit> = BTreeMap::new();
let mut taken_in: BTreeMap<EntryId, Vec<Taken>> = BTreeMap::new();
let mut out = BTreeMap::new();
for e in walk {
let lines = crate::k1::entry_lines(ledger, e, members, cash, places)?;
let wholes = entry_wholes(ledger, e, &lines, n, cash)?;
let mut parts = EntryParts::default();
if let Some(back) = e.reversal_of().and_then(|o| taken_in.remove(&o)) {
for (amount, set, whole, weights, given) in back {
counts
.get_mut(&(amount, set))
.expect("a split taken in has its count")
.unsplit(whole, &weights, &given)?;
}
for (amount, whole, weights) in wholes {
parts.0[amount] = RunningSplit::new(n).split(whole, &weights)?;
}
} else {
let mut taken = Vec::new();
for (amount, whole, weights) in wholes {
let set: Vec<usize> = (0..n).filter(|&i| !weights[i].is_zero()).collect();
let sub: Vec<Decimal> = set.iter().map(|&i| weights[i]).collect();
let sub_parts = counts
.entry((amount, set.clone()))
.or_insert_with(|| RunningSplit::new(set.len()))
.split(whole, &sub)?;
let mut full = vec![Decimal::ZERO; n];
for (k, &i) in set.iter().enumerate() {
full[i] = sub_parts[k];
}
parts.0[amount] = full;
taken.push((amount, set, whole, sub, sub_parts));
}
if !taken.is_empty() {
taken_in.insert(e.id(), taken);
}
}
out.insert(e.id(), parts);
}
Ok(out)
}
/// What one contribution entry's property is on the tax basis.
@@ -448,6 +797,7 @@ fn span(
members: &[MemberCapital],
cash: &[AccountId],
prior: Option<&TaxCapital>,
parts: &std::collections::BTreeMap<EntryId, EntryParts>,
) -> Result<TaxCapital, LedgerError> {
let detail = k1_detail(ledger, start, end, members, cash)?;
let n = members.len();
@@ -478,21 +828,27 @@ fn span(
})
.collect()
};
let none = EntryParts::default();
let part = parts.get(&id).unwrap_or(&none);
let takes = lines(K1Kind::PropertyContribution);
if takes.iter().any(|t| !t.is_zero()) {
let p = entry_property(ledger, e, cash)?;
let g = share(p.built_in_gain, &takes)?;
let w = share(p.without_basis, &takes)?;
let pp = share(p.prepaid, &takes)?;
// Only the members with property in the entry have a part of
// it (entry_parts INVARIANT 2).
for i in (0..n).filter(|&i| !takes[i].is_zero()) {
if !pp[i].is_zero() {
let (g, w, pp) = (
part.get(BUILT_IN_GAIN, i),
part.get(WITHOUT_BASIS, i),
part.get(PREPAID, i),
);
if !pp.is_zero() {
prop_notes[i].push(Note::PrepaidAtBook {
entry: id,
book: pp[i],
book: pp,
});
}
big[i] = big[i].checked_add(g[i]).ok_or_else(too_large)?;
without[i] = without[i].checked_add(w[i]).ok_or_else(too_large)?;
big[i] = big[i].checked_add(g).ok_or_else(too_large)?;
without[i] = without[i].checked_add(w).ok_or_else(too_large)?;
if let Some((linked, property)) = p.mismatch {
prop_open[i].push(Open::LinkedValueExceedsEntry {
entry: id,
@@ -500,43 +856,17 @@ fn span(
property,
});
}
if !w[i].is_zero() {
prop_open[i].push(Open::PropertyBasis {
entry: id,
book: w[i],
});
if !w.is_zero() {
prop_open[i].push(Open::PropertyBasis { entry: id, book: w });
}
}
}
// Distributions are negative lines; weights are what each took.
let taken: Vec<Decimal> = lines(K1Kind::Distribution)
.into_iter()
.map(|d| -d)
.collect();
if taken.iter().any(|t| *t > Decimal::ZERO) {
let property = e
.lines()
.iter()
.filter(|l| {
l.side == Side::Credit
&& crate::k1::is_property_account(ledger, cash, l.account)
})
.try_fold(Decimal::ZERO, |t, l| t.checked_add(l.amount.amount))
.ok_or_else(too_large)?;
let total_taken = taken
.iter()
.try_fold(Decimal::ZERO, |t, x| t.checked_add(*x))
.ok_or_else(too_large)?;
let property = property.min(total_taken);
if property > Decimal::ZERO {
let parts = share(property, &taken)?;
for i in (0..n).filter(|&i| !parts[i].is_zero()) {
distributed[i] = distributed[i].checked_add(parts[i]).ok_or_else(too_large)?;
dist_open[i].push(Open::PropertyDistribution {
entry: id,
book: parts[i],
});
}
// Property distributed, by what each took.
for i in 0..n {
let book = part.get(DISTRIBUTED, i);
if !book.is_zero() {
distributed[i] = distributed[i].checked_add(book).ok_or_else(too_large)?;
dist_open[i].push(Open::PropertyDistribution { entry: id, book });
}
}
}
@@ -681,25 +1011,131 @@ mod tests {
Decimal::from_str(s).unwrap()
}
/// share: proportional, to the cent, largest remainder ties to the
/// first; negative wholes negated; zero weights give zeros.
fn split(r: &mut RunningSplit, whole: &str, weights: &[&str]) -> Vec<Decimal> {
let w: Vec<Decimal> = weights.iter().map(|x| d(x)).collect();
r.split(d(whole), &w).unwrap()
}
/// RunningSplit: one split is proportional, to the cent, largest
/// remainder ties to the first; a negative whole mirrors a positive
/// one; zero weights give zeros; weights that don't pair are refused.
#[test]
fn share_is_exact_and_signed() {
fn one_split_is_exact_and_signed() {
let mut r = RunningSplit::new(2);
assert_eq!(
share(d("-1021.37"), &[d("525"), d("525")]).unwrap(),
split(&mut r, "-1021.37", &["525", "525"]),
vec![d("-510.69"), d("-510.68")]
);
let mut r = RunningSplit::new(2);
assert_eq!(
share(d("36"), &[d("525"), d("525")]).unwrap(),
split(&mut r, "36", &["525", "525"]),
vec![d("18.00"), d("18.00")]
);
let mut r = RunningSplit::new(2);
assert_eq!(split(&mut r, "10", &["0", "0"]), vec![d("0"), d("0")]);
assert_eq!(r.running(), &[d("0"), d("0")]);
let mut r = RunningSplit::new(2);
assert_eq!(
share(d("10"), &[d("0"), d("0")]).unwrap(),
vec![d("0"), d("0")]
);
assert_eq!(
share(d("0.01"), &[d("1"), d("2")]).unwrap(),
split(&mut r, "0.01", &["1", "2"]),
vec![d("0.00"), d("0.01")]
);
assert!(r.split(d("1"), &[d("1")]).is_err());
}
/// RunningSplit scenario 1: three odd cents 50/50 alternate, so the
/// members end one cent apart, never three.
#[test]
fn tied_odd_cents_alternate() {
let mut r = RunningSplit::new(2);
assert_eq!(
split(&mut r, "-0.01", &["1", "1"]),
vec![d("-0.01"), d("0.00")]
);
assert_eq!(
split(&mut r, "-0.01", &["1", "1"]),
vec![d("0.00"), d("-0.01")]
);
assert_eq!(
split(&mut r, "-0.01", &["1", "1"]),
vec![d("-0.01"), d("0.00")]
);
assert_eq!(r.running(), &[d("-0.02"), d("-0.01")]);
}
/// RunningSplit INVARIANT 3: a member with no weight in a split takes
/// back the cent their running total no longer earns.
#[test]
fn a_member_outside_a_split_is_trued_up() {
let mut r = RunningSplit::new(3);
// A and B 50/50 of 0.01: exact 0.005 each, the cent to A.
assert_eq!(
split(&mut r, "0.01", &["1", "1", "0"]),
vec![d("0.01"), d("0"), d("0")]
);
// B and C 1:3 of 0.02: exact 0.005 / 0.01 / 0.015 of 0.03; A and
// C tie on remainders, the odd cent stays with A.
assert_eq!(
split(&mut r, "0.02", &["0", "1", "3"]),
vec![d("0"), d("0.01"), d("0.01")]
);
// B and C 2:1 of 0.01: exact 0.005 / 0.01667 / 0.01833 of 0.04;
// C's and B's remainders now beat A's, so A gives the cent back.
assert_eq!(
split(&mut r, "0.01", &["0", "2", "1"]),
vec![d("-0.01"), d("0.01"), d("0.01")]
);
assert_eq!(r.running(), &[d("0"), d("0.02"), d("0.02")]);
}
/// RunningSplit scenario 3, INVARIANT 2: at a running whole of zero
/// the rounding takes the sign of the whole just split, so a run of
/// losses mirrors its run of gains part for part (P4 review: rounded
/// as a gain at zero, the negated run ended 0.01 / −0.01, moving the
/// member with no weight).
#[test]
fn a_loss_mirrors_a_gain_at_zero() {
let mut gain = RunningSplit::new(2);
let mut loss = RunningSplit::new(2);
for (whole, weights, want) in [
("0.01", ["1", "1"], ["0.01", "0"]),
("-0.01", ["1", "0"], ["-0.02", "0.01"]),
] {
let g = split(&mut gain, whole, &weights);
let l = split(&mut loss, &format!("{}", -d(whole)), &weights);
assert_eq!(g, vec![d(want[0]), d(want[1])]);
assert_eq!(l, g.iter().map(|x| -x).collect::<Vec<_>>());
}
assert_eq!(gain.running(), &[d("-0.01"), d("0.01")]);
assert_eq!(loss.running(), &[d("0.01"), d("-0.01")]);
}
/// RunningSplit INVARIANT 5: a split taken back out leaves the next
/// split as if it never was.
#[test]
fn unsplit_takes_a_split_back_out() {
let mut r = RunningSplit::new(2);
let parts = split(&mut r, "-12.37", &["1", "1"]);
assert_eq!(parts, vec![d("-6.19"), d("-6.18")]);
r.unsplit(d("-12.37"), &[d("1"), d("1")], &parts).unwrap();
assert_eq!(r, RunningSplit::new(2));
assert_eq!(
split(&mut r, "-55.91", &["1", "1"]),
vec![d("-27.96"), d("-27.95")]
);
assert!(r.unsplit(d("1"), &[d("1")], &parts).is_err());
assert!(r.unsplit(d("1"), &[d("1"), d("1")], &[d("1")]).is_err());
}
/// RunningSplit INVARIANT 4: thirds summed in Decimal come out a hair
/// under the cents they are (399.93 and 791.11); taken to ten places
/// they are those cents, and the split goes through.
#[test]
fn thirds_summed_are_the_cents_they_are() {
let mut r = RunningSplit::new(2);
split(&mut r, "283.43", &["4", "2"]);
split(&mut r, "274.68", &["0", "1"]);
split(&mut r, "824.32", &["2", "4"]);
split(&mut r, "-191.39", &["1", "2"]);
assert_eq!(r.running(), &[d("399.93"), d("791.11")]);
}
}
+2 -2
View File
@@ -3281,8 +3281,8 @@ fn every_bookspec_example_holds() {
("register_row", 5),
("register_totals", 1),
("asset_basis", 6),
("k1_tax_basis", 2),
("k1_reconciliation", 1),
("k1_tax_basis", 4),
("k1_reconciliation", 2),
("project", 6),
] {
let n = checked.get(q).copied().unwrap_or(0);
@@ -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 56101dd675399c931df47369972f415782f4cd0e6ea874c37ef655b9961b69cb # shrinks to n = 2, splits = [(28343, [4, 2, 0, 0]), (27468, [0, 1, 0, 0]), (82432, [2, 4, 0, 0]), (-19139, [1, 2, 0, 0])]
+407 -2
View File
@@ -14,11 +14,12 @@ use std::str::FromStr;
use chrono::NaiveDate;
use ledger_core::reports::MemberCapital;
use ledger_core::tax_capital::{Note, Open, Reconciliation, tax_basis_capital};
use ledger_core::tax_capital::{Note, Open, Reconciliation, RunningSplit, tax_basis_capital};
use ledger_core::{
AccountId, AccountType, AssetBasis, AssetId, BasisRule, Currency, EntryId, Ledger, Money,
NewAsset, NewSchedule, ScheduleConvention, ScheduleKind, Sec704cMethod, Side,
};
use proptest::prelude::*;
use rust_decimal::Decimal;
fn d(s: &str) -> Decimal {
@@ -97,10 +98,22 @@ fn members(b: &Books) -> [MemberCapital; 2] {
/// A device two members contribute, in service 2026-01-01 over 24 months.
fn device(b: &mut Books, name: &str, fmv: &str, adjusted: &str, entry: EntryId) -> AssetId {
device_on(b, name, fmv, adjusted, entry, date(2026, 1, 1))
}
/// A device two members contribute, in service `on` over 24 months.
fn device_on(
b: &mut Books,
name: &str,
fmv: &str,
adjusted: &str,
entry: EntryId,
on: NaiveDate,
) -> AssetId {
let id =
b.l.register_asset(NewAsset {
description: name.into(),
in_service_date: date(2026, 1, 1),
in_service_date: on,
cost: usd(fmv),
salvage_value: usd("0.00"),
useful_life_months: 24,
@@ -734,3 +747,395 @@ fn unequal_contributors_and_a_member_with_none() {
assert_eq!(none.ending.known, d("0"));
assert_eq!(none.reconciliation, Reconciliation::default());
}
/// The odd cent on the tax basis runs (B-36 with D-38; owner, 2026-10-22):
/// three devices contributed 50/50, each worth less than its basis by an
/// odd number of cents, the built-in gain shared cumulatively, so the
/// members' tax property ends one cent apart, never three. By hand:
/// - 2026-01-01 a monitor, 400.00 (basis 412.37, gain −12.37): exact
/// −6.185 each, the tied cent to Tobin: −6.19 / −6.18;
/// - 2026-06-01 a chair, 250.00 (basis 263.41, gain −13.41): running
/// −25.78, exact −12.89 each: −6.70 / −6.71;
/// - 2026-11-01 a desk, 600.00 (basis 655.91, gain −55.91): running
/// −81.69, exact −40.845 each, the tied cent to Tobin: −27.96 / −27.95.
///
/// Running built-in gain −40.85 / −40.84; book property 625.00 each, tax
/// 665.85 / 665.84. Shared entry by entry, Tobin took all three tied
/// cents: 665.86 / 665.83. From June: book 425.00 each; gain −34.66 each
/// (−6.70 − 27.96, −6.71 − 27.95), tax 459.66 each, on a beginning of
/// 206.19 / 206.18, ending at the year's figures.
#[test]
fn tied_odd_cents_of_property_basis_run_cumulatively() {
let mut b = books();
let (equipment, tobin, morgan, cash) = (b.equipment, b.tobin, b.morgan, b.cash);
for (on, name, fmv, adjusted, half) in [
(date(2026, 1, 1), "Monitor", "400.00", "412.37", "200.00"),
(date(2026, 6, 1), "Chair", "250.00", "263.41", "125.00"),
(date(2026, 11, 1), "Desk", "600.00", "655.91", "300.00"),
] {
let e = post(
&mut b.l,
on,
&[
(equipment, Side::Debit, fmv),
(tobin, Side::Credit, half),
(morgan, Side::Credit, half),
],
);
device_on(&mut b, name, fmv, adjusted, e, on);
}
let ms = members(&b);
let year = tax_basis_capital(&b.l, date(2026, 1, 1), date(2026, 12, 31), &ms, &[cash]).unwrap();
let from_june =
tax_basis_capital(&b.l, date(2026, 6, 1), date(2026, 12, 31), &ms, &[cash]).unwrap();
for (i, (tax, big, beginning)) in [
("665.85", "-40.85", "206.19"),
("665.84", "-40.84", "206.18"),
]
.into_iter()
.enumerate()
{
let row = &year.rows[i];
assert_eq!(row.book.contributions_property, d("625.00"));
assert_eq!(row.contributions_property.tax(), Some(d(tax)));
assert_eq!(row.reconciliation.built_in_gain_704c, d(big));
assert_eq!(row.ending.known, d(tax));
let june = &from_june.rows[i];
assert_eq!(june.beginning.known, d(beginning));
assert_eq!(june.book.contributions_property, d("425.00"));
assert_eq!(june.contributions_property.tax(), Some(d("459.66")));
assert_eq!(june.reconciliation.built_in_gain_704c, d("-34.66"));
assert_eq!(june.ending.known, row.ending.known, "the spans chain");
}
}
/// A third member, Avery Quinn, beside Tobin and Morgan (made up), with
/// shares 0.5 / 0.3 / 0.2.
fn three_members(b: &mut Books) -> (AccountId, [MemberCapital; 3]) {
let third =
b.l.add_account(
"3002",
"Capital, Avery Quinn",
AccountType::Equity,
None,
false,
)
.unwrap();
let [t, m] = members(b);
(
third,
[
MemberCapital {
share: d("0.5"),
..t
},
MemberCapital {
share: d("0.3"),
..m
},
MemberCapital {
name: "Avery Quinn".into(),
capital_account: third,
distribution_account: None,
share: d("0.2"),
},
],
)
}
/// A member never takes a cent of an entry they had no part in (B-36,
/// Form 1065 Item L: capital contributed is the basis of the property
/// that member contributed; P4 review, 2026-10-22). Three members; by
/// hand, each set of contributing members its own running split:
/// - 2026-01-01 Tobin and Morgan, 50.00 each of a 100.00 device (basis
/// 100.01, gain −0.01): −0.01 / 0.00, the tied cent to Tobin;
/// - 2026-02-01 Morgan and Avery, 25.00 / 75.00 of a 100.00 device
/// (basis 100.02, gain −0.02): exact −0.005 / −0.015, the tied cent to
/// Morgan: −0.01 / −0.01;
/// - 2027-01-01 Morgan and Avery, 60.00 / 30.00 of a 90.00 device (basis
/// 90.01, gain −0.01): Morgan and Avery's count runs −0.03, exact
/// −0.011667 / −0.018333, rounded −0.01 / −0.02: 0.00 / −0.01.
///
/// 2027: Tobin nothing at all; Morgan 60.00 at tax, Avery 30.01, together
/// the device's §723 basis 90.01. One running split for all three
/// members instead (the first cut) gives Tobin, who put in nothing in
/// 2027, −0.01 of tax property and +0.01 of built-in gain, Morgan and
/// Avery 60.01 / 30.01, more than the basis.
#[test]
fn a_member_outside_an_entry_never_takes_its_cents() {
let mut b = books();
let (equipment, tobin, morgan, cash) = (b.equipment, b.tobin, b.morgan, b.cash);
let (avery, ms) = three_members(&mut b);
for (on, value, basis, credits) in [
(
date(2026, 1, 1),
"100.00",
"100.01",
[(tobin, "50.00"), (morgan, "50.00")],
),
(
date(2026, 2, 1),
"100.00",
"100.02",
[(morgan, "25.00"), (avery, "75.00")],
),
(
date(2027, 1, 1),
"90.00",
"90.01",
[(morgan, "60.00"), (avery, "30.00")],
),
] {
let e = post(
&mut b.l,
on,
&[
(equipment, Side::Debit, value),
(credits[0].0, Side::Credit, credits[0].1),
(credits[1].0, Side::Credit, credits[1].1),
],
);
device_on(&mut b, "Device", value, basis, e, on);
}
let year = |y| tax_basis_capital(&b.l, date(y, 1, 1), date(y, 12, 31), &ms, &[cash]).unwrap();
let (y26, y27) = (year(2026), year(2027));
// Each year's built-in shares sum to its entries' gains, and the tax
// property to their bases.
for (tc, gain, basis) in [(&y26, "-0.03", "200.03"), (&y27, "-0.01", "90.01")] {
let big: Decimal = tc
.rows
.iter()
.map(|r| r.reconciliation.built_in_gain_704c)
.sum();
assert_eq!(big, d(gain));
let tax: Decimal = tc
.rows
.iter()
.map(|r| r.contributions_property.tax().unwrap())
.sum();
assert_eq!(tax, d(basis));
}
for (row, (book, tax, big)) in y26.rows.iter().zip([
("50.00", "50.01", "-0.01"),
("75.00", "75.01", "-0.01"),
("75.00", "75.01", "-0.01"),
]) {
assert_eq!(
row.book.contributions_property,
d(book),
"{}",
row.book.name
);
assert_eq!(row.contributions_property.tax(), Some(d(tax)));
assert_eq!(row.reconciliation.built_in_gain_704c, d(big));
}
for (row, (book, tax, big)) in y27.rows.iter().zip([
("0", "0", "0"),
("60.00", "60.00", "0"),
("30.00", "30.01", "-0.01"),
]) {
assert_eq!(
row.book.contributions_property,
d(book),
"{}",
row.book.name
);
assert_eq!(row.contributions_property.tax(), Some(d(tax)));
assert_eq!(row.reconciliation.built_in_gain_704c, d(big));
}
let t = &y27.rows[0].contributions_property;
assert!(t.open.is_empty() && t.notes.is_empty(), "{t:?}");
}
/// An entry's odd cent is the same whatever span the worksheet is asked
/// for (B-36; P4 review, 2026-10-22). Tobin and Morgan, 50/50: a monitor
/// contributed 2026-01-01 (400.00, basis 412.37, gain −12.37), reversed
/// 2026-07-01; a desk contributed 2026-11-01 (600.00, basis 655.91, gain
/// −55.91). The reversal takes the monitor's split back out of the count,
/// so the desk is split as if the monitor never was: exact −27.955 each,
/// the tied cent to Tobin, −27.96 / −27.95, in every span. The first cut
/// carried the count span by span: from June the monitor was split in the
/// span before and its reversal read as a distribution, so the desk's
/// cent went to Morgan there and to Tobin in the others.
#[test]
fn an_entrys_odd_cent_is_the_same_in_every_span() {
let mut b = books();
let (equipment, tobin, morgan, cash) = (b.equipment, b.tobin, b.morgan, b.cash);
let mut entries = Vec::new();
for (on, name, fmv, adjusted, half) in [
(date(2026, 1, 1), "Monitor", "400.00", "412.37", "200.00"),
(date(2026, 11, 1), "Desk", "600.00", "655.91", "300.00"),
] {
let e = post(
&mut b.l,
on,
&[
(equipment, Side::Debit, fmv),
(tobin, Side::Credit, half),
(morgan, Side::Credit, half),
],
);
device_on(&mut b, name, fmv, adjusted, e, on);
entries.push(e);
}
b.l.reverse_entry(entries[0], date(2026, 7, 1), "monitor back")
.unwrap();
let ms = members(&b);
for start in [
date(2026, 1, 1),
date(2026, 6, 1),
date(2026, 8, 1),
date(2026, 10, 1),
] {
let tc = tax_basis_capital(&b.l, start, date(2026, 12, 31), &ms, &[cash]).unwrap();
let big: Vec<Decimal> = tc
.rows
.iter()
.map(|r| r.reconciliation.built_in_gain_704c)
.collect();
assert_eq!(big, vec![d("-27.96"), d("-27.95")], "from {start}");
}
}
/// The running split takes the entries in date order, whatever order
/// they were posted in, so next year's beginning is this year's ending
/// to the cent (B-36). Tobin and Morgan, 50/50: a chair dated 2027-03-01
/// (250.00, basis 263.41) posted first, then a monitor dated 2026-01-01
/// (400.00, basis 412.37). The monitor comes first: −6.19 / −6.18, then
/// the chair −6.70 / −6.71. Taken in posting order the 2027 worksheet
/// would split the chair first and the monitor −6.18 / −6.19 in its
/// beginning, a cent from the 2026 worksheet's ending.
#[test]
fn the_split_runs_in_date_order() {
let mut b = books();
let (equipment, tobin, morgan, cash) = (b.equipment, b.tobin, b.morgan, b.cash);
for (on, name, fmv, adjusted, half) in [
(date(2027, 3, 1), "Chair", "250.00", "263.41", "125.00"),
(date(2026, 1, 1), "Monitor", "400.00", "412.37", "200.00"),
] {
let e = post(
&mut b.l,
on,
&[
(equipment, Side::Debit, fmv),
(tobin, Side::Credit, half),
(morgan, Side::Credit, half),
],
);
device_on(&mut b, name, fmv, adjusted, e, on);
}
let ms = members(&b);
let year = |y| tax_basis_capital(&b.l, date(y, 1, 1), date(y, 12, 31), &ms, &[cash]).unwrap();
let (y26, y27) = (year(2026), year(2027));
for (i, (big26, big27)) in [("-6.19", "-6.70"), ("-6.18", "-6.71")]
.into_iter()
.enumerate()
{
assert_eq!(y26.rows[i].reconciliation.built_in_gain_704c, d(big26));
assert_eq!(y27.rows[i].reconciliation.built_in_gain_704c, d(big27));
assert_eq!(y27.rows[i].beginning.known, y26.rows[i].ending.known);
}
}
/// lcm(1..=16): every sum of up to four weights of 0..=4 divides it. It
/// is under 10^8, so two exact running totals that differ at all differ
/// by more than RunningSplit's ten places can blur (INVARIANT 4).
const LCM: i128 = 720_720;
/// The largest-remainder rounding to the cent of exact running totals
/// `exact` (in cents × LCM) summing to `total` cents, ties to the earlier
/// member, a negative total (or a zero one after a negative whole,
/// `negative`) rounded as its magnitude and negated: RunningSplit
/// INVARIANT 2 in integers.
fn largest_remainder(exact: &[i128], total: i128, negative: bool) -> Vec<i128> {
let sign = if total < 0 || (total == 0 && negative) {
-1
} else {
1
};
let floors: Vec<i128> = exact.iter().map(|x| (sign * x).div_euclid(LCM)).collect();
let rems: Vec<i128> = exact.iter().map(|x| (sign * x).rem_euclid(LCM)).collect();
let short = sign * total - floors.iter().sum::<i128>();
assert!((0..=exact.len() as i128).contains(&short));
let rank = |i: usize| {
(0..rems.len())
.filter(|&j| rems[j] > rems[i] || (rems[j] == rems[i] && j < i))
.count() as i128
};
(0..exact.len())
.map(|i| sign * (floors[i] + i128::from(rank(i) < short)))
.collect()
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(5000))]
/// Law: RunningSplit INVARIANTS 1 and 2 (B-36 with D-38): for any run
/// of splits, wholes of either sign to the cent (a third of them a few
/// cents, so the running whole often comes back to zero) and each its own
/// weights among up to four members (small ratios, scaled by a cent
/// amount up to ten million, so the quotients are a cent amount's),
/// each split's parts sum to its whole, the running totals sum to
/// everything split, every member's running total is the
/// largest-remainder rounding of the exact running totals, ties to the
/// earlier member (so never a cent from exact), checked in exact
/// integers, and the run with every whole negated gives every part
/// negated.
#[test]
fn a_running_split_never_drifts_a_cent(
n in 1usize..=4,
splits in prop::collection::vec(
(
prop_oneof![-3i64..=3, -100_000i64..=100_000, -1_000_000_000_000i64..=1_000_000_000_000],
prop::collection::vec(0u8..=4, 4),
prop_oneof![Just(1i64), 1i64..=1_000_000_000],
),
1..24,
),
) {
let mut r = RunningSplit::new(n);
let mut mirror = RunningSplit::new(n);
let mut exact = vec![0i128; n];
let mut total = 0i128;
for (whole, weights, scale) in &splits {
let w = &weights[..n];
let sum: i128 = w.iter().map(|&x| i128::from(x)).sum();
let ws: Vec<Decimal> = w
.iter()
.map(|&x| Decimal::new(i64::from(x) * scale, 2))
.collect();
let parts = r.split(Decimal::new(*whole, 2), &ws).unwrap();
let negated = mirror.split(Decimal::new(-*whole, 2), &ws).unwrap();
prop_assert_eq!(parts.len(), n);
prop_assert_eq!(
negated,
parts.iter().map(|p| -p).collect::<Vec<_>>(),
"a loss mirrors a gain"
);
let split: Decimal = parts.iter().sum();
if sum == 0 || *whole == 0 {
prop_assert_eq!(split, Decimal::ZERO);
continue;
}
prop_assert_eq!(split, Decimal::new(*whole, 2));
total += i128::from(*whole);
for (x, &wi) in exact.iter_mut().zip(w) {
*x += i128::from(*whole) * i128::from(wi) * (LCM / sum);
}
let running: Decimal = r.running().iter().sum();
prop_assert_eq!(running, Decimal::from_i128_with_scale(total, 2));
let want = largest_remainder(&exact, total, *whole < 0);
for (i, (&running, &exact)) in r.running().iter().zip(&exact).enumerate() {
let cents = (running * Decimal::ONE_HUNDRED).normalize();
prop_assert_eq!(cents.scale(), 0, "a running total to the cent");
let cents = i128::try_from(cents).unwrap();
prop_assert_eq!(cents, want[i], "member {}: the largest-remainder rounding", i);
prop_assert!(
(cents * LCM - exact).abs() < LCM,
"member {} at {} against exact {}/{}",
i, cents, exact, LCM
);
}
}
}
}