Split kernel::entry_flows into entry_movements and flows_of so the reversal-mirror proof fits CI; prove runs the fast harnesses, prove-slow the 8 slow ones (own job in scheduled CI), prove-all both. Laws in verification.md renumbered 1-31 with a table of all 55 harnesses. MCP docs checked against the 115 tools (about 60 fixes) with a test that every tool is documented; rules.md cross-references fixed; bookspec statuses and loader tightened; viewer notes wrap and accounts sort by code; STATUS.md says P3 is built, cutover pending. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
49 files changed
+963
-292
No files matched your search
@@ -11,7 +11,7 @@
|
||||
//! — and postings go through the normal entry machinery, so every
|
||||
//! accounting invariant holds.
|
||||
//!
|
||||
//! Two figures of accumulated depreciation, never confused (rules.md B-24):
|
||||
//! Two figures of accumulated depreciation, never confused (rules.md B-15 rule 8):
|
||||
//! what the schedule says is due through a date
|
||||
//! ([`accumulated_scheduled_through`]) and what the books carry, the runs
|
||||
//! posted and not reversed ([`crate::Ledger::accumulated_posted`]). Book
|
||||
@@ -743,7 +743,7 @@ fn schedule_months(asset: &Asset, through: NaiveDate) -> Result<Vec<MonthLine>,
|
||||
/// (inclusive), exact to the minor unit ([`depreciation_schedule`]): the
|
||||
/// tools' `accumulated_scheduled_through`. What the books carry is
|
||||
/// [`crate::Ledger::accumulated_posted`]; the two agree once every month
|
||||
/// due is posted (rules.md B-24).
|
||||
/// due is posted (rules.md B-15 rule 8).
|
||||
pub fn accumulated_scheduled_through(
|
||||
asset: &Asset,
|
||||
as_of: NaiveDate,
|
||||
|
||||
@@ -680,7 +680,17 @@ pub fn apply_cash<T: Amount>(cash: T, movements: &[T], applied: &mut [T]) -> Opt
|
||||
/// operating, investing, financing, each up to its own movement; what is
|
||||
/// left of a section's movement is the part no cash settled. `None` when
|
||||
/// the lines don't net to zero (never for a posted entry) or on overflow.
|
||||
/// It is [`entry_movements`] then [`flows_of`], so each is proved on its
|
||||
/// own (docs/verification.md, law 10).
|
||||
pub fn entry_flows<T: Amount>(lines: &[(Flow, Side, T)]) -> Option<EntryFlows<T>> {
|
||||
let (cash, moved) = entry_movements(lines)?;
|
||||
flows_of(cash, moved)
|
||||
}
|
||||
|
||||
/// An entry's net movement on its cash lines and on each section's,
|
||||
/// `(cash, [operating, investing, financing])`, debit-positive. `None`
|
||||
/// on overflow.
|
||||
pub fn entry_movements<T: Amount>(lines: &[(Flow, Side, T)]) -> Option<(T, [T; 3])> {
|
||||
let mut cash = T::ZERO;
|
||||
let mut moved = [T::ZERO; 3];
|
||||
for &(flow, side, amount) in lines {
|
||||
@@ -693,6 +703,13 @@ pub fn entry_flows<T: Amount>(lines: &[(Flow, Side, T)]) -> Option<EntryFlows<T>
|
||||
};
|
||||
*slot = slot.checked_add(delta)?;
|
||||
}
|
||||
Some((cash, moved))
|
||||
}
|
||||
|
||||
/// The cash flows of an entry whose cash lines moved `cash` and whose
|
||||
/// sections moved `moved` ([`entry_flows`]). `None` when they don't net
|
||||
/// to zero, or on overflow.
|
||||
pub fn flows_of<T: Amount>(cash: T, moved: [T; 3]) -> Option<EntryFlows<T>> {
|
||||
// Balanced lines only: every part below rests on it.
|
||||
let mut total = cash;
|
||||
for &m in &moved {
|
||||
|
||||
@@ -2534,7 +2534,7 @@ impl Ledger {
|
||||
/// ```text
|
||||
/// GOAL: a run the books record is the entry that posted it, no more
|
||||
/// and no less, so accumulated_posted is what the books carry.
|
||||
/// SOURCE: rules.md B-24; B-28 (new commands are bounded; replay checks
|
||||
/// SOURCE: rules.md B-15 rules 6 and 8, B-24; B-28 (new commands are bounded; replay checks
|
||||
/// only what every journaled run passed:
|
||||
/// record_depreciation_run).
|
||||
/// ```
|
||||
|
||||
@@ -85,7 +85,7 @@ pub type PayoutPlanLine = (AccountId, Side, Decimal, Option<ClassId>);
|
||||
/// collected; fees debit expense accounts), and the payout reconciles to
|
||||
/// the cent.
|
||||
/// A zero deposit (fees and adjustments ate the sales exactly) has no
|
||||
/// deposit line: every planned line is positive (B-17, docs/spec/rules.md).
|
||||
/// deposit line: every planned line is positive (B-2, docs/spec/rules.md).
|
||||
pub fn payout_plan(
|
||||
payout: &Payout,
|
||||
chart: &ChartOfAccounts,
|
||||
|
||||
@@ -1031,12 +1031,17 @@ fn entry_flows_are_bounded() {
|
||||
|
||||
/// A reversal's cash flows are its original's, negated, part by part:
|
||||
/// what lets an entry and its reversal in one period drop out of the
|
||||
/// statement together (rules.md B-14 rule 6).
|
||||
/// statement together (rules.md B-14 rule 6). End to end, on entries of
|
||||
/// up to two lines: at three (`NF`) one run went past half an hour at
|
||||
/// four jobs on a busy machine (2026-10-21), so three lines are proved in
|
||||
/// two halves, `entry_movements_of_a_reversal_mirror` and
|
||||
/// `flows_of_mirrors`, and Lean `flows_reversal` proves it at any size.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(5)]
|
||||
fn entry_flows_of_a_reversal_mirror() {
|
||||
let all = any_flow_lines();
|
||||
let n = any_flow_len();
|
||||
kani::assume(n <= 2);
|
||||
let lines = &all[..n];
|
||||
// Element by element (see `kani_symbolic_copy_canary`).
|
||||
let mut rev = [(Flow::Cash, Side::Debit, 0i64); NF];
|
||||
@@ -1046,6 +1051,67 @@ fn entry_flows_of_a_reversal_mirror() {
|
||||
let a = kernel::entry_flows(lines);
|
||||
let b = kernel::entry_flows(&rev[..n]);
|
||||
assert_eq!(a.is_some(), b.is_some());
|
||||
if let (Some(a), Some(b)) = (a, b) {
|
||||
assert_eq!(b.cash, -a.cash);
|
||||
for k in 0..3 {
|
||||
assert_eq!(b.flows[k], -a.flows[k]);
|
||||
assert_eq!(b.noncash[k], -a.noncash[k]);
|
||||
}
|
||||
kani::cover!(a.flows[1] != 0, "cash settling investing, reversed");
|
||||
}
|
||||
}
|
||||
|
||||
/// The two halves of `entry_flows_of_a_reversal_mirror`, each for every
|
||||
/// input in its bounds: `kernel::entry_flows` is `entry_movements` then
|
||||
/// `flows_of`, so a reversal mirrors when its movements are its
|
||||
/// original's negated (this harness, up to `NF` lines each in any of the
|
||||
/// four places) and `flows_of` is odd (the next, for any movements three
|
||||
/// lines can make). Together they prove the end-to-end harness's claim,
|
||||
/// whose single run past half an hour at four jobs on a busy machine
|
||||
/// (2026-10-21) moved it to `just prove-slow`.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(5)]
|
||||
fn entry_movements_of_a_reversal_mirror() {
|
||||
let all = any_flow_lines();
|
||||
let n = any_flow_len();
|
||||
let lines = &all[..n];
|
||||
// Element by element (see `kani_symbolic_copy_canary`).
|
||||
let mut rev = [(Flow::Cash, Side::Debit, 0i64); NF];
|
||||
for i in 0..n {
|
||||
rev[i] = kernel::reverse_line(lines[i]);
|
||||
}
|
||||
let a = kernel::entry_movements(lines);
|
||||
let b = kernel::entry_movements(&rev[..n]);
|
||||
assert_eq!(a.is_some(), b.is_some());
|
||||
if let (Some((ca, ma)), Some((cb, mb))) = (a, b) {
|
||||
assert_eq!(cb, -ca);
|
||||
for k in 0..3 {
|
||||
assert_eq!(mb[k], -ma[k]);
|
||||
}
|
||||
kani::cover!(
|
||||
n == NF && ca != 0 && ma[1] != 0,
|
||||
"three lines, cash and investing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `kernel::flows_of` is odd: the negated movements give the negated
|
||||
/// flows, part by part, and succeed exactly when the original's do; for
|
||||
/// any cash and section movements up to `NF` lines' worth.
|
||||
#[kani::proof]
|
||||
#[kani::unwind(5)]
|
||||
fn flows_of_mirrors() {
|
||||
let wide_amount = || {
|
||||
let a: i64 = kani::any();
|
||||
// What up to NF in-range lines can sum to (`in_line_range`: 2^40).
|
||||
kani::assume(a.unsigned_abs() <= (NF as u64) << 40);
|
||||
a
|
||||
};
|
||||
let cash = wide_amount();
|
||||
let moved: [i64; 3] = core::array::from_fn(|_| wide_amount());
|
||||
let a = kernel::flows_of(cash, moved);
|
||||
let b = kernel::flows_of(-cash, [-moved[0], -moved[1], -moved[2]]);
|
||||
assert_eq!(a.is_some(), b.is_some());
|
||||
if let (Some(a), Some(b)) = (a, b) {
|
||||
assert_eq!(b.cash, -a.cash);
|
||||
for k in 0..3 {
|
||||
|
||||
@@ -743,9 +743,14 @@ fn bookspec_source(id: &str) -> Source {
|
||||
fn decision(ex: &str, what: &str, kind: Option<&str>, reason: Option<&str>) -> Decision1099 {
|
||||
match what {
|
||||
"not_reportable" => {
|
||||
let kind = kind.and_then(ExemptReason::parse).unwrap_or_else(|| {
|
||||
panic!("{ex}: not_reportable needs a reason_kind, got {kind:?}")
|
||||
});
|
||||
// Exactly as the README spells it: the engine's parse forgives
|
||||
// case and spaces, an example doesn't.
|
||||
let kind = kind
|
||||
.and_then(ExemptReason::parse)
|
||||
.filter(|k| Some(k.key()) == kind)
|
||||
.unwrap_or_else(|| {
|
||||
panic!("{ex}: not_reportable needs a reason_kind, got {kind:?}")
|
||||
});
|
||||
let reason = reason.unwrap_or_else(|| panic!("{ex}: not_reportable needs a reason"));
|
||||
Decision1099::not_reportable(kind, reason).unwrap_or_else(|e| panic!("{ex}: {e}"))
|
||||
}
|
||||
@@ -1058,7 +1063,7 @@ impl<'a> Books<'a> {
|
||||
for p in &s.payees {
|
||||
let what = format!("{}: payee {}", ex.id, p.name);
|
||||
let form = Form1099::parse(&p.form)
|
||||
.filter(|_| p.form.starts_with("1099-"))
|
||||
.filter(|f| f.label() == p.form)
|
||||
.unwrap_or_else(|| panic!("{what}: form {:?}", p.form));
|
||||
// A payee in the directory with the vendor role is that
|
||||
// party's vendor record.
|
||||
@@ -1099,6 +1104,7 @@ impl<'a> Books<'a> {
|
||||
for i in &s.instruments {
|
||||
let what = format!("{} instrument {}", ex.id, i.label);
|
||||
let kind = InstrumentKind::parse(&i.kind)
|
||||
.filter(|k| k.key() == i.kind)
|
||||
.unwrap_or_else(|| panic!("{what}: kind {:?}", i.kind));
|
||||
let account = id(store.ledger(), &i.account);
|
||||
let owner = i.owner.as_ref().map(|o| {
|
||||
@@ -1959,7 +1965,11 @@ fn check_party_totals(b: &Books<'_>, x: &Expect) {
|
||||
let basis = x
|
||||
.basis
|
||||
.as_deref()
|
||||
.map(|k| Basis::parse(k).unwrap_or_else(|| panic!("{}: basis {k:?}", ex.id)))
|
||||
.map(|k| {
|
||||
Basis::parse(k)
|
||||
.filter(|b| b.key() == k)
|
||||
.unwrap_or_else(|| panic!("{}: basis {k:?}", ex.id))
|
||||
})
|
||||
.unwrap_or_else(|| panic!("{}: party_totals names its basis", ex.id));
|
||||
let party = expect_party(b, x);
|
||||
let a = ledger_core::parties::activity_with(
|
||||
@@ -2035,6 +2045,11 @@ fn check_k1(ex: &Example, l: &Ledger, members: &[MemberCapital], x: &Expect) {
|
||||
"{}: k1 needs the members and their shares",
|
||||
ex.id
|
||||
);
|
||||
assert!(
|
||||
!ex.setup.cash_accounts.is_empty(),
|
||||
"{}: k1 needs cash_accounts (what is money rather than property)",
|
||||
ex.id
|
||||
);
|
||||
let cash = codes(l, &ex.setup.cash_accounts, &ex.id);
|
||||
let rows = ledger_core::k1::k1_worksheet(l, from, to, members, &cash)
|
||||
.unwrap_or_else(|e| panic!("{}: {e}", ex.id));
|
||||
@@ -3427,6 +3442,37 @@ fn a_payment_method_not_in_the_setup_is_refused() {
|
||||
});
|
||||
}
|
||||
|
||||
/// A value from a closed list is spelled exactly as bookspec/README.md
|
||||
/// spells it: the engine's own parse forgives case and spaces, the
|
||||
/// loader doesn't (a spec has one spelling).
|
||||
#[test]
|
||||
#[should_panic(expected = "form \"1099-nec\"")]
|
||||
fn a_form_in_another_case_is_refused() {
|
||||
run_edited("1099-a-dated-status-applies-by-payment-date.toml", |t| {
|
||||
t.replacen("form = \"1099-NEC\"", "form = \"1099-nec\"", 1)
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "kind \"Business_Card\"")]
|
||||
fn a_payment_method_kind_in_another_case_is_refused() {
|
||||
run_edited("1099-card-payments-are-excluded.toml", |t| {
|
||||
t.replacen("kind = \"business_card\"", "kind = \"Business_Card\"", 1)
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "not_reportable needs a reason_kind")]
|
||||
fn a_reason_kind_in_another_case_is_refused() {
|
||||
run_edited("1099-a-dated-status-applies-by-payment-date.toml", |t| {
|
||||
t.replacen(
|
||||
"reason_kind = \"corporation\"",
|
||||
"reason_kind = \"Corporation\"",
|
||||
1,
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
/// An event the example says the engine refuses must be refused: one it
|
||||
/// takes fails the example (March's run reversed on its own day is taken).
|
||||
#[test]
|
||||
|
||||
@@ -787,7 +787,7 @@ fn cash_flow_reconciles_to_change_in_cash() {
|
||||
|
||||
// Operating, by the direct method: collected 1,500 (class (a)); paid
|
||||
// 2,500 for equipment (not a registered asset: operating, rules.md
|
||||
// B-14's limit R4) and 800 rent (class (d)) = -1,800. Reconciled:
|
||||
// B-14 rule 12's limits) and 800 rent (class (d)) = -1,800. Reconciled:
|
||||
// 3,200 net income - 2,500 increase in AR (4,000 billed - 1,500
|
||||
// collected) - 2,500 increase in equipment = -1,800.
|
||||
// Financing: +10,000 owner contribution. Sum = 8,200. Reconciles.
|
||||
|
||||
@@ -596,7 +596,7 @@ mod p3 {
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(500))]
|
||||
|
||||
/// B-15 INVARIANT 5 (defect 22): accumulated depreciation never falls,
|
||||
/// depreciation_schedule INVARIANT 5 (rules.md B-15; defect 22): accumulated depreciation never falls,
|
||||
/// never exceeds cost − salvage, is 0 before the in-service date and
|
||||
/// cost − salvage from the life's last day on; each schedule's lines
|
||||
/// sum to its accumulated and none is negative. Sampled through: every
|
||||
@@ -719,7 +719,7 @@ proptest! {
|
||||
}
|
||||
}
|
||||
|
||||
/// B-15 INVARIANT 6 (P3 depreciation brief 2.3, R1, R8): through every
|
||||
/// B-15 rule 4 (depreciation_schedule INVARIANT 6; P3 depreciation brief 2.3, R1, R8): through every
|
||||
/// month end of the life, the schedule is the pre-P3 running total
|
||||
/// (USD, a two-place currency), except where the exact cumulative is a
|
||||
/// half-cent tie, which the old 28-digit Decimal quotients could round
|
||||
|
||||
@@ -647,7 +647,7 @@ fn defect24_an_accrual_through_another_liability_counts_once() {
|
||||
assert_eq!(b.totals(c, Basis::All), (Decimal::ZERO, d("1200.00")));
|
||||
}
|
||||
|
||||
/// Defect 24 and B-35 item 1: a credit to what the company owes a party
|
||||
/// Defect 24 and B-35 item 6: a credit to what the company owes a party
|
||||
/// itself never counts as paying it. A fee on the issuer's own card is a
|
||||
/// bill from the issuer (counted when the card is paid); a distribution
|
||||
/// still owed to a member counts when it is paid; equipment bought on the
|
||||
|
||||
Reference in new issue
Block a user