closes: a run of members closes splits cumulatively
check / check (push) Canceled after 0s

Each close into members' capital rounded on its own, ties to the
earlier member, so twelve monthly 50/50 closes of odd-cent income could
leave one member several cents ahead of half the year (docs/testing.md
#12, found with the members' equity fix).

- CloseTo::Members now splits cumulatively over a run (closes back to
  back with the same members and shares): each close gives a member
  allocate(run total so far) - allocate(run total before). Periods
  record their close order and a cumulative close's members and net
  (Period::close_seq, member_close) so the next close continues the run
  and replay recomputes it exactly. A close to anything else, or other
  shares, ends the run.
- Journaled "rounding": "cumulative". The per-close rule is kept as
  CloseTo::MembersPerClose for closes journaled "largest_remainder";
  older closes still replay with the last member absorbing it.
- Lean: runPart_telescopes (a member's total from a run is their
  allocation of the run's total) and closePart_total (each close hands
  out exactly its net), audited to the standard axioms.
- Tests: hand-computed three one-cent months (per close A 0.03 / B 0;
  cumulative A 0.02 / B 0.01), a run ending, a property that each member
  stays within a cent of their share (fails on the per-close rule), and
  a store contract that each rule journals and replays as itself.

Wanderland's books have no closes yet, so all of theirs use the new rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-13 18:58:21 -06:00
1 parent f4381f170c
commit fb3b4ab476
11 files changed
+416 -16

No files matched your search

+14 -7
View File
@@ -1099,7 +1099,9 @@ impl Store {
CloseTo::RetainedEarnings { sweep } if sweep.is_empty() => json!("retained_earnings"),
CloseTo::RetainedEarnings { sweep } => json!({"retained_earnings": {"sweep": sweep}}),
CloseTo::Account { into, sweep } => json!({"account": {"into": into, "sweep": sweep}}),
CloseTo::Members(members) | CloseTo::MembersLastAbsorbs(members) => {
CloseTo::Members(members)
| CloseTo::MembersPerClose(members)
| CloseTo::MembersLastAbsorbs(members) => {
let mut v = json!({
"members": members
.iter()
@@ -1110,10 +1112,13 @@ impl Store {
}))
.collect::<Vec<_>>(),
});
// Closes journaled before 2026-10-13 carry no rounding and
// replay with the last member absorbing it.
if matches!(to, CloseTo::Members(_)) {
v["rounding"] = json!("largest_remainder");
// The rounding rule, so replay closes the same way. Closes
// journaled before 2026-10-13 carry none (the last member
// absorbed it).
match to {
CloseTo::Members(_) => v["rounding"] = json!("cumulative"),
CloseTo::MembersPerClose(_) => v["rounding"] = json!("largest_remainder"),
_ => {}
}
v
}
@@ -3487,10 +3492,12 @@ fn close_to_from_journal(v: Option<&Value>) -> Result<CloseTo, StoreError> {
})
.collect::<Result<_, StoreError>>()?;
// The rounding rule the close was made under: absent before
// 2026-10-13, when the last member absorbed it.
// 2026-10-13, when the last member absorbed it; "largest_remainder"
// per close, then "cumulative" over a run of closes.
match v.get("rounding").and_then(|r| r.as_str()) {
None => Ok(CloseTo::MembersLastAbsorbs(members)),
Some("largest_remainder") => Ok(CloseTo::Members(members)),
Some("largest_remainder") => Ok(CloseTo::MembersPerClose(members)),
Some("cumulative") => Ok(CloseTo::Members(members)),
Some(_) => Err(corrupt("unknown rounding")),
}
}