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

+48 -3
View File
@@ -26,8 +26,21 @@ use serde_json::{Value, json};
/// The newest journal format this version reads and writes.
pub const SUPPORTED_JOURNAL_FORMAT: u32 = 2;
/// Optional journal features this version reads (none yet).
pub const KNOWN_FEATURES: &[&str] = &[];
/// Optional journal features this version reads. Each is a field or value
/// on an event type an older version knows, which that version would
/// misread silently; a writer declares the feature (`journal.format_set`)
/// before its first such line, so the older version stops there instead
/// (rules.md B-27). Books that never use one stay readable by versions
/// before it.
pub const KNOWN_FEATURES: &[&str] = &[MEMBERS_CUMULATIVE];
/// Feature: `period.closed` closes to members with `"rounding":
/// "cumulative_since"` and `since_seq`, the count of members closes
/// starting at the first since the members or shares last changed (P3
/// defect 17, rules.md B-13). An older version would call the rounding
/// unknown, or (before the format gate) replay the close under the run
/// rule, which can split it differently.
pub const MEMBERS_CUMULATIVE: &str = "members_cumulative";
/// The line type that declares the journal format.
pub const FORMAT_SET: &str = "journal.format_set";
@@ -188,6 +201,12 @@ impl JournalFormat {
/// Read a `journal.format_set` line, refusing a format or feature this
/// version doesn't read.
pub(crate) fn declared(line: &JournalLine) -> Result<JournalFormat, StoreError> {
declared_knowing(line, KNOWN_FEATURES)
}
/// [`declared`] by a version that knows the features `known`: what an
/// older version, which knew fewer, does with the same line.
fn declared_knowing(line: &JournalLine, known: &[&str]) -> Result<JournalFormat, StoreError> {
let p = &line.payload;
let format = p
.get("format")
@@ -216,7 +235,7 @@ pub(crate) fn declared(line: &JournalLine) -> Result<JournalFormat, StoreError>
let unknown: Vec<&str> = features
.iter()
.map(String::as_str)
.filter(|f| !KNOWN_FEATURES.contains(f))
.filter(|f| !known.contains(f))
.collect();
if !unknown.is_empty() {
return Err(newer(
@@ -485,4 +504,30 @@ mod tests {
));
}
}
/// P3 defect 17's feature (members_cumulative): this version reads it;
/// a version before it (KNOWN_FEATURES empty, as through P2) stops at
/// its declaration with NewerJournal, never replaying a close counted
/// from its regime's start under the run rule.
#[test]
fn an_older_reader_stops_at_the_members_cumulative_feature() {
assert!(KNOWN_FEATURES.contains(&MEMBERS_CUMULATIVE));
let l = line(
FORMAT_SET,
json!({"format": 2, "features": [MEMBERS_CUMULATIVE]}),
);
assert_eq!(
declared(&l).unwrap(),
JournalFormat {
format: 2,
features: vec![MEMBERS_CUMULATIVE.to_string()]
}
);
match declared_knowing(&l, &[]) {
Err(StoreError::NewerJournal { need, .. }) => {
assert_eq!(need, "journal features members_cumulative")
}
other => panic!("expected NewerJournal, got {other:?}"),
}
}
}
+80 -12
View File
@@ -583,6 +583,29 @@ impl Store {
Ok(())
}
/// Declare a journal feature (crate::format::KNOWN_FEATURES) before the
/// first event that needs it: a field or value on a known event type
/// that an older version would otherwise misread silently. Declared
/// with the format in force (at least 2) and every feature already in
/// force; nothing is journaled when it is already declared. Books that
/// never use the feature stay readable by older versions.
///
/// ```text
/// GOAL: an older wanderbooks stops at the declaration (NewerJournal)
/// instead of misreading a line it thinks it knows.
/// SOURCE: rules.md B-27; plan/served.md 5.1 (format gate).
/// ```
fn ensure_feature(&mut self, feature: &str) -> Result<(), StoreError> {
if self.format.features.iter().any(|f| f == feature) {
return Ok(());
}
let format = self.format.format.max(crate::meta::FORMAT);
let mut features = self.format.features.clone();
features.push(feature.to_string());
let features: Vec<&str> = features.iter().map(String::as_str).collect();
self.declare_journal_format(format, &features)
}
/// Change settings (`settings.set`): all or nothing, each value
/// canonical, the result whole (ledger_core::settings). Returns the
/// changes made; none (journaling nothing) when every change is the
@@ -1798,6 +1821,11 @@ impl Store {
// ensure then no-ops and replay stays deterministic.
self.ensure_control_account(&ledger_core::RETAINED_EARNINGS)?;
}
if matches!(to, CloseTo::Members(_) | CloseTo::MembersCumulative { .. }) {
// A close counted from its regime's start: an older version
// would replay it under the run rule (format gate, B-27).
self.ensure_feature(crate::format::MEMBERS_CUMULATIVE)?;
}
let mut next = self.ledger.clone();
let closing_entry = next.close_period_to(id, to)?;
let entry_detail = match closing_entry {
@@ -1818,9 +1846,8 @@ 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::MembersPerClose(members)
| CloseTo::MembersLastAbsorbs(members) => {
_ => {
let members = to.members().expect("every other target is members");
let mut v = json!({
"members": members
.iter()
@@ -1835,7 +1862,18 @@ impl Store {
// journaled before 2026-10-13 carry none (the last member
// absorbed it).
match to {
CloseTo::Members(_) => v["rounding"] = json!("cumulative"),
CloseTo::Members(_) | CloseTo::MembersCumulative { .. } => {
let rule = next
.get_period(id)
.and_then(|p| p.members_closed.as_ref())
.map(|m| m.rule);
let Some(ledger_core::MemberRule::Cumulative { since_seq }) = rule else {
unreachable!("a members close records its cumulative rule")
};
v["rounding"] = json!("cumulative_since");
v["since_seq"] = json!(since_seq);
}
CloseTo::MembersRun(_) => v["rounding"] = json!("cumulative"),
CloseTo::MembersPerClose(_) => v["rounding"] = json!("largest_remainder"),
_ => {}
}
@@ -4739,7 +4777,7 @@ pub(crate) fn replay_line_into_ledger(
// Deterministic: the close re-derives the closing entry from
// the replayed state and the journaled target (absent: Retained
// Earnings), so it must match the journal's id and every line.
let to = close_to_from_journal(p.get("close_to"))?;
let to = close_to_from_journal(line, p.get("close_to"))?;
let got = ledger.close_period_to(id, &to)?;
if got != want {
return Err(StoreError::ReplayMismatch(format!(
@@ -5021,7 +5059,10 @@ fn tax_basis_from_journal(p: &Value) -> Result<ledger_core::TaxBasis, StoreError
})
}
fn close_to_from_journal(v: Option<&Value>) -> Result<CloseTo, StoreError> {
/// A journaled close's target. A rounding rule only a newer version
/// writes (an unknown name, or a value that isn't a name) is newer, never
/// corrupt (rules.md B-27).
fn close_to_from_journal(line: &JournalLine, v: Option<&Value>) -> Result<CloseTo, StoreError> {
let corrupt = |what: &str| StoreError::JournalCorrupt(format!("close_to: {what}"));
let ids = |v: &Value| -> Result<Vec<AccountId>, StoreError> {
v.as_array()
@@ -5062,12 +5103,39 @@ 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; "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::MembersPerClose(members)),
Some("cumulative") => Ok(CloseTo::Members(members)),
Some(_) => Err(corrupt("unknown rounding")),
// per close, then "cumulative" over a run of closes, then (P3)
// "cumulative_since" the close numbered since_seq.
let rounding = match v.get("rounding") {
None | Some(Value::Null) => None,
Some(Value::String(r)) => Some(r.as_str()),
Some(other) => {
return Err(crate::format::newer_content(
line,
format!("a members close's rounding {other}"),
));
}
};
let since_seq = v.get("since_seq");
let older = |to: CloseTo| match since_seq {
// Only "cumulative_since" counts from a close.
Some(_) => Err(corrupt("since_seq beside another rounding")),
None => Ok(to),
};
match rounding {
None => older(CloseTo::MembersLastAbsorbs(members)),
Some("largest_remainder") => older(CloseTo::MembersPerClose(members)),
Some("cumulative") => older(CloseTo::MembersRun(members)),
Some("cumulative_since") => Ok(CloseTo::MembersCumulative {
members,
since_seq: since_seq
.and_then(Value::as_u64)
.filter(|&s| s >= 1)
.ok_or_else(|| corrupt("since_seq"))?,
}),
Some(other) => Err(crate::format::newer_content(
line,
format!("a members close's rounding {other:?}"),
)),
}
}