The money arithmetic every invariant rests on now lives in one kernel,
ledger-core/src/kernel.rs: signing a line, totals, net, per-account
movements, reversal, normal balances, trial-balance columns, closing
lines, and the rounding plug for member splits. It is generic over a
checked `Amount` trait. The engine runs it on Decimal (validate,
movements, reverse_entry, account_balance, close_period_to, allocate,
trial_balance): there is one copy of the arithmetic, and it is the one
under proof.
Three layers, docs/verification.md maps every law to its proofs:
- Kani (src/proofs.rs): 17 harnesses prove the kernel at i64 for every
input up to 4 lines over 3 accounts. An entry is accepted exactly
when it is two or more positive, in-range lines whose debits equal
credits; accepted entries net to zero; grouping by account loses
nothing; a reversal mirrors and cancels its original; trial-balance
columns balance iff the books net to zero; closing lines zero their
accounts and a close balances however the net is split; the plug
makes parts sum to the whole. No panics, no overflow. kani::cover!
shows the proofs are not vacuous.
- Lean 4 (verify/lean): 24 headline theorems for books of any size. The
ledger is a state machine (add account, draft, post, reverse, open,
close), and in every reachable state the books net to zero, the trial
balance balances (as of any date), and the accounting equation holds.
Posting only appends ("posted is forever"), closed periods are
frozen, a close zeroes revenue and expenses and credits exactly the net
income, and period statements add up. Audit.lean shows standard axioms
only, no sorry.
- Bridge (tests/kernel_bridge.rs): the Decimal and i64 kernels agree.
What the proofs found (each one missed by every test, fuzzer, and
mutant):
- The engine accepted negative line amounts; only MCP refused them. Now
NegativeAmountLine: direction is the side's job.
- Summing lines near Decimal::MAX panicked. Lines are now capped at one
quadrillion and ten decimal places (AmountOutOfRange), which keeps
every realistic sum exact, and the kernel's sums are checked.
- FX conversion (foreign_amount * fx_rate) could panic the same way; it
is now a checked multiply.
- Kani itself: 0.68 mis-models symbolic-length copy_from_slice
(model-checking/kani#4904) and reported a reversal that doesn't net to
zero. The counterexample, replayed natively, passes, and a 15-line
standalone crate reproduces the bug. Harnesses avoid bulk copies, and
a should_panic canary will flag the upstream fix.
`just prove` runs it all (Kani memory- and time-capped, fails on an
unsatisfiable cover or a non-standard Lean axiom). AGENTS.md now makes
formal coverage part of every core change.
Also, docs: GnuCash is *an* oracle, never *the* oracle (Stephen). The
spec is accounting principles, proved. Golden files are recomputed from
principles. External implementations (GnuCash, the old Sheet, QBO/Xero)
are cross-checks whose disagreements get adjudicated, never obeyed.
docs/oracle.md gains "Who is right"; AGENTS, README, architecture,
roadmap, parity, and STATUS are reworded to match.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
321 lines
11 KiB
Lean4
321 lines
11 KiB
Lean4
import Wanderbooks.Statements
|
||
|
||
/-!
|
||
# Closing a period, and splitting the net
|
||
|
||
Mirrors `kernel::closing_line`, `kernel::credit_line`, `kernel::plug_last`,
|
||
`periods::allocate`, and `Ledger::close_period_to` (income closing, net to
|
||
one equity account or split among members).
|
||
-/
|
||
|
||
namespace Wanderbooks
|
||
|
||
/-- The line that zeroes an account with movement `m`: the opposite side,
|
||
same amount; nothing for zero (`kernel::closing_line`). It is also the
|
||
line that credits `m` to an account (`kernel::credit_line`). -/
|
||
def closingLine (a : Nat) (m : Int) : List Line :=
|
||
if 0 < m then [⟨a, .credit, m⟩] else if m < 0 then [⟨a, .debit, -m⟩] else []
|
||
|
||
theorem net_closingLine (a : Nat) (m : Int) : net (closingLine a m) = -m := by
|
||
unfold closingLine
|
||
split
|
||
· simp [Line.signed]
|
||
· split <;> simp [Line.signed] <;> omega
|
||
|
||
theorem movement_closingLine (b a : Nat) (m : Int) :
|
||
movement b (closingLine a m) = if b = a then -m else 0 := by
|
||
by_cases e : b = a
|
||
· subst e
|
||
rcases (by omega : 0 < m ∨ m < 0 ∨ m = 0) with h | h | h
|
||
· simp [closingLine, h, Line.signed]
|
||
· have : ¬ 0 < m := by omega
|
||
simp [closingLine, h, this, Line.signed]
|
||
· simp [closingLine, h]
|
||
· have e' : ¬ a = b := fun x => e x.symm
|
||
unfold closingLine
|
||
split <;> (try split) <;> simp [e']
|
||
|
||
theorem closingLine_pos (a : Nat) (m : Int) : ∀ l ∈ closingLine a m, 0 < l.amount := by
|
||
unfold closingLine
|
||
split
|
||
· simp; omega
|
||
· split
|
||
· simp; omega
|
||
· simp
|
||
|
||
theorem closingLine_account (a : Nat) (m : Int) : ∀ l ∈ closingLine a m, l.account = a := by
|
||
unfold closingLine
|
||
split
|
||
· simp
|
||
· split <;> simp
|
||
|
||
theorem closingLine_eq_nil (a : Nat) (m : Int) : closingLine a m = [] ↔ m = 0 := by
|
||
unfold closingLine
|
||
split
|
||
· simp; omega
|
||
· split
|
||
· simp; omega
|
||
· simp; omega
|
||
|
||
/-- The closing lines for some accounts: one per account with a nonzero
|
||
movement. -/
|
||
def closings (as : List Nat) (ls : List Line) : List Line :=
|
||
match as with
|
||
| [] => []
|
||
| a :: as => closingLine a (movement a ls) ++ closings as ls
|
||
|
||
theorem net_closings (as : List Nat) (ls : List Line) :
|
||
net (closings as ls) = -sumOver (fun a => movement a ls) as := by
|
||
induction as with
|
||
| nil => rfl
|
||
| cons a as ih => simp only [closings, net_append, net_closingLine, ih, sumOver_cons]; omega
|
||
|
||
theorem movement_closings (b : Nat) (as : List Nat) (ls : List Line) (hd : Distinct as) :
|
||
movement b (closings as ls) = if b ∈ as then -movement b ls else 0 := by
|
||
induction as with
|
||
| nil => simp [closings]
|
||
| cons a as ih =>
|
||
obtain ⟨ha, hd⟩ := hd
|
||
simp only [closings, movement_append, movement_closingLine, ih hd]
|
||
by_cases e : b = a
|
||
· subst e; simp [ha]
|
||
· simp [e]
|
||
|
||
theorem closings_pos (as : List Nat) (ls : List Line) : ∀ l ∈ closings as ls, 0 < l.amount := by
|
||
induction as with
|
||
| nil => simp [closings]
|
||
| cons a as ih =>
|
||
intro l hl
|
||
simp only [closings, List.mem_append] at hl
|
||
cases hl with
|
||
| inl h => exact closingLine_pos _ _ l h
|
||
| inr h => exact ih l h
|
||
|
||
theorem closings_account (as : List Nat) (ls : List Line) : ∀ l ∈ closings as ls, l.account ∈ as := by
|
||
induction as with
|
||
| nil => simp [closings]
|
||
| cons a as ih =>
|
||
intro l hl
|
||
simp only [closings, List.mem_append] at hl
|
||
cases hl with
|
||
| inl h => rw [closingLine_account _ _ l h]; simp
|
||
| inr h => exact List.mem_cons_of_mem _ (ih l h)
|
||
|
||
/-- Nothing to close means every closed account was already at zero. -/
|
||
theorem closings_eq_nil (as : List Nat) (ls : List Line) (h : closings as ls = []) :
|
||
∀ a ∈ as, movement a ls = 0 := by
|
||
induction as with
|
||
| nil => simp
|
||
| cons a as ih =>
|
||
simp only [closings, List.append_eq_nil_iff] at h
|
||
intro b hb
|
||
cases hb with
|
||
| head => exact (closingLine_eq_nil _ _).1 h.1
|
||
| tail _ hb => exact ih h.2 b hb
|
||
|
||
/-! ## The net to equity -/
|
||
|
||
/-- The sum of some amounts. -/
|
||
def total : List Int → Int
|
||
| [] => 0
|
||
| x :: xs => x + total xs
|
||
|
||
/-- Lines crediting each target its part (`kernel::credit_line` per
|
||
member). -/
|
||
def equityLines : List (Nat × Int) → List Line
|
||
| [] => []
|
||
| (t, c) :: ts => closingLine t c ++ equityLines ts
|
||
|
||
theorem net_equityLines (ts : List (Nat × Int)) :
|
||
net (equityLines ts) = -total (ts.map Prod.snd) := by
|
||
induction ts with
|
||
| nil => rfl
|
||
| cons x ts ih =>
|
||
obtain ⟨t, c⟩ := x
|
||
simp only [equityLines, net_append, net_closingLine, ih, List.map_cons, total]; omega
|
||
|
||
theorem movement_equityLines_absent (b : Nat) (ts : List (Nat × Int))
|
||
(h : ∀ x ∈ ts, x.1 ≠ b) : movement b (equityLines ts) = 0 := by
|
||
induction ts with
|
||
| nil => rfl
|
||
| cons x ts ih =>
|
||
obtain ⟨t, c⟩ := x
|
||
have ht : b ≠ t := fun e => h (t, c) (by simp) e.symm
|
||
simp only [equityLines, movement_append, movement_closingLine, ht, ite_false,
|
||
ih (fun y hy => h y (List.mem_cons_of_mem _ hy))]
|
||
rfl
|
||
|
||
theorem equityLines_pos (ts : List (Nat × Int)) : ∀ l ∈ equityLines ts, 0 < l.amount := by
|
||
induction ts with
|
||
| nil => simp [equityLines]
|
||
| cons x ts ih =>
|
||
obtain ⟨t, c⟩ := x
|
||
intro l hl
|
||
simp only [equityLines, List.mem_append] at hl
|
||
cases hl with
|
||
| inl h => exact closingLine_pos _ _ l h
|
||
| inr h => exact ih l h
|
||
|
||
theorem equityLines_account (ts : List (Nat × Int)) :
|
||
∀ l ∈ equityLines ts, ∃ x ∈ ts, l.account = x.1 := by
|
||
induction ts with
|
||
| nil => simp [equityLines]
|
||
| cons x ts ih =>
|
||
obtain ⟨t, c⟩ := x
|
||
intro l hl
|
||
simp only [equityLines, List.mem_append] at hl
|
||
cases hl with
|
||
| inl h => exact ⟨(t, c), by simp, closingLine_account _ _ l h⟩
|
||
| inr h =>
|
||
obtain ⟨y, hy, e⟩ := ih l h
|
||
exact ⟨y, List.mem_cons_of_mem _ hy, e⟩
|
||
|
||
theorem equityLines_eq_nil (ts : List (Nat × Int)) (h : equityLines ts = []) :
|
||
total (ts.map Prod.snd) = 0 := by
|
||
induction ts with
|
||
| nil => rfl
|
||
| cons x ts ih =>
|
||
obtain ⟨t, c⟩ := x
|
||
simp only [equityLines, List.append_eq_nil_iff] at h
|
||
simp only [List.map_cons, total, (closingLine_eq_nil _ _).1 h.1, ih h.2]; rfl
|
||
|
||
/-- The closing entry: zero the closed accounts, credit the net to the
|
||
targets. -/
|
||
def closingEntry (as : List Nat) (ts : List (Nat × Int)) (ls : List Line) : List Line :=
|
||
closings as ls ++ equityLines ts
|
||
|
||
/-- **A closing entry balances** when the targets' parts sum to the net
|
||
of the closing lines (the net income), however that net was split. -/
|
||
theorem closingEntry_net (as : List Nat) (ts : List (Nat × Int)) (ls : List Line)
|
||
(hsum : total (ts.map Prod.snd) = net (closings as ls)) :
|
||
net (closingEntry as ts ls) = 0 := by
|
||
rw [closingEntry, net_append, net_equityLines, hsum]; omega
|
||
|
||
/-- **A closing entry zeroes every account it closes.** -/
|
||
theorem closingEntry_zeroes (as : List Nat) (ts : List (Nat × Int)) (ls : List Line)
|
||
(hd : Distinct as) (b : Nat) (hb : b ∈ as) (ht : ∀ x ∈ ts, x.1 ≠ b) :
|
||
movement b (ls ++ closingEntry as ts ls) = 0 := by
|
||
rw [movement_append, closingEntry, movement_append, movement_closings b as ls hd,
|
||
movement_equityLines_absent b ts ht]
|
||
simp only [hb, ite_true]; omega
|
||
|
||
/-- **A non-empty closing entry is acceptable**: two or more lines, every
|
||
amount positive, debits equal to credits. -/
|
||
theorem closingEntry_accepted (as : List Nat) (ts : List (Nat × Int)) (ls : List Line)
|
||
(hsum : total (ts.map Prod.snd) = net (closings as ls)) (hne : closings as ls ≠ []) :
|
||
Accepted (closingEntry as ts ls) := by
|
||
refine ⟨?_, ?_, ?_⟩
|
||
· rw [closingEntry, List.length_append]
|
||
match hc : closings as ls with
|
||
| [] => exact absurd hc hne
|
||
| [l] =>
|
||
have hl : 0 < l.amount := closings_pos as ls l (by rw [hc]; simp)
|
||
have hnet : net (closings as ls) ≠ 0 := by
|
||
rw [hc]; obtain ⟨a, s, m⟩ := l
|
||
cases s <;> simp [Line.signed] at hl ⊢ <;> omega
|
||
have : equityLines ts ≠ [] := fun h => hnet (hsum ▸ equityLines_eq_nil ts h)
|
||
have : 1 ≤ (equityLines ts).length := by
|
||
cases h : equityLines ts with
|
||
| nil => exact absurd h this
|
||
| cons _ _ => simp
|
||
simp; omega
|
||
| _ :: _ :: _ => simp; omega
|
||
· intro l hl
|
||
simp only [closingEntry, List.mem_append] at hl
|
||
cases hl with
|
||
| inl h => exact closings_pos as ls l h
|
||
| inr h => exact equityLines_pos ts l h
|
||
· have := closingEntry_net as ts ls hsum
|
||
rw [net_eq_debits_sub_credits] at this; omega
|
||
|
||
/-! ## Net income -/
|
||
|
||
/-- The chart's revenue and expense codes: what a close zeroes. -/
|
||
def nominalCodes : Chart → List Nat
|
||
| [] => []
|
||
| (a, t) :: c => if t = .revenue ∨ t = .expense then a :: nominalCodes c else nominalCodes c
|
||
|
||
theorem nominalCodes_sub (c : Chart) : ∀ a ∈ nominalCodes c, a ∈ codes c := by
|
||
induction c with
|
||
| nil => simp [nominalCodes]
|
||
| cons x c ih =>
|
||
obtain ⟨a, t⟩ := x
|
||
intro b hb
|
||
simp only [nominalCodes] at hb
|
||
split at hb
|
||
· cases hb with
|
||
| head => simp
|
||
| tail _ h => exact List.mem_cons_of_mem _ (ih b h)
|
||
· exact List.mem_cons_of_mem _ (ih b hb)
|
||
|
||
theorem nominalCodes_distinct (c : Chart) (hd : Distinct (codes c)) : Distinct (nominalCodes c) := by
|
||
induction c with
|
||
| nil => trivial
|
||
| cons x c ih =>
|
||
obtain ⟨a, t⟩ := x
|
||
obtain ⟨ha, hd⟩ := hd
|
||
simp only [nominalCodes]
|
||
split
|
||
· exact ⟨fun h => ha (nominalCodes_sub c a h), ih hd⟩
|
||
· exact ih hd
|
||
|
||
/-- **The net a close credits to equity is the period's net income**:
|
||
revenue balances minus expense balances. -/
|
||
theorem net_closings_is_net_income (c : Chart) (ls : List Line) :
|
||
net (closings (nominalCodes c) ls) = typeTotal .revenue ls c - typeTotal .expense ls c := by
|
||
rw [net_closings]
|
||
induction c with
|
||
| nil => rfl
|
||
| cons x c ih =>
|
||
obtain ⟨a, t⟩ := x
|
||
cases t <;> simp [nominalCodes, typeTotal, balance, AccountType.normal, signed] at ih ⊢ <;> omega
|
||
|
||
/-! ## Splitting among members -/
|
||
|
||
/-- Make rounded parts sum to the whole: the last becomes the whole minus
|
||
the others (`kernel::plug_last`). -/
|
||
def plugLast (whole : Int) : List Int → List Int
|
||
| [] => []
|
||
| [_] => [whole]
|
||
| p :: q :: rest => p :: plugLast (whole - p) (q :: rest)
|
||
|
||
/-- **An allocation's parts sum to the whole, exactly**, however the
|
||
other parts were rounded. -/
|
||
theorem plugLast_total (whole : Int) (ps : List Int) (h : ps ≠ []) :
|
||
total (plugLast whole ps) = whole := by
|
||
induction ps generalizing whole with
|
||
| nil => exact absurd rfl h
|
||
| cons p rest ih =>
|
||
match rest, ih with
|
||
| [], _ => show total [whole] = whole; simp [total]
|
||
| q :: rest, ih =>
|
||
simp only [plugLast, total]
|
||
have := ih (whole - p) (by simp)
|
||
omega
|
||
|
||
theorem plugLast_length (whole : Int) (ps : List Int) : (plugLast whole ps).length = ps.length := by
|
||
induction ps generalizing whole with
|
||
| nil => rfl
|
||
| cons p rest ih =>
|
||
match rest, ih with
|
||
| [], _ => rfl
|
||
| q :: rest, ih => simp only [plugLast, List.length_cons]; rw [ih]; rfl
|
||
|
||
/-- Only the last part changes. -/
|
||
theorem plugLast_keeps (whole : Int) (ps : List Int) :
|
||
(plugLast whole ps).dropLast = ps.dropLast := by
|
||
induction ps generalizing whole with
|
||
| nil => rfl
|
||
| cons p rest ih =>
|
||
match rest, ih with
|
||
| [], _ => rfl
|
||
| q :: rest, ih =>
|
||
have hne : plugLast (whole - p) (q :: rest) ≠ [] := by
|
||
intro h
|
||
have := plugLast_length (whole - p) (q :: rest)
|
||
rw [h] at this; simp at this
|
||
rw [show plugLast whole (p :: q :: rest) = p :: plugLast (whole - p) (q :: rest) from rfl,
|
||
List.dropLast_cons_of_ne_nil hne, ih, List.dropLast_cons_cons]
|
||
|
||
end Wanderbooks
|