feat(core): prove the accounting laws (Kani + Lean); fix three engine gaps

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
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-13 11:12:13 -06:00
1 parent d5c4e38b18
commit 3609bf065e
31 files changed
+2682 -131

No files matched your search

+9
View File
@@ -26,6 +26,15 @@ pub enum LedgerError {
#[error("journal line has a zero amount")]
ZeroAmountLine,
/// Line amounts are positive; the side says which way the money moves.
#[error("journal line has a negative amount; use the other side instead")]
NegativeAmountLine,
/// A line larger than one quadrillion, or with more than ten decimal
/// places, is a mistake, and one that could overflow exact arithmetic.
#[error("journal line amount is out of range (at most 1000000000000000, ten decimal places)")]
AmountOutOfRange,
/// The referenced account does not exist in the chart of accounts.
#[error("unknown account id {0}")]
UnknownAccount(u64),
+27 -27
View File
@@ -4,6 +4,7 @@
//! total credits. Entries are born as drafts, validated, then posted. Posting
//! is one-way; a posted entry can only be corrected by a reversing entry.
use crate::kernel::{self, LineError};
use crate::{AccountId, ChartOfAccounts, ClassId, Currency, LedgerError, Money, Side};
use chrono::NaiveDate;
use rust_decimal::Decimal;
@@ -126,14 +127,11 @@ impl JournalEntry {
}
/// Validate the entry against the chart of accounts. This is the heart of
/// R-1: at least two lines, no zero amounts, no placeholder or unknown
/// accounts, one currency throughout, and debits exactly equal to credits.
/// R-1: at least two lines, every amount positive and within line range,
/// no placeholder or unknown accounts, one currency throughout, and
/// debits exactly equal to credits. The arithmetic is
/// [`kernel::check_lines`].
pub fn validate(&self, chart: &ChartOfAccounts) -> Result<(), LedgerError> {
if self.lines.len() < 2 {
return Err(LedgerError::TooFewLines);
}
let mut debits = Decimal::ZERO;
let mut credits = Decimal::ZERO;
let mut currency: Option<Currency> = None;
for line in &self.lines {
let account = chart
@@ -142,9 +140,6 @@ impl JournalEntry {
if account.placeholder {
return Err(LedgerError::PlaceholderAccount(account.code.clone()));
}
if line.amount.is_zero() {
return Err(LedgerError::ZeroAmountLine);
}
match currency {
None => currency = Some(line.amount.currency),
Some(c) if c != line.amount.currency => {
@@ -161,28 +156,33 @@ impl JournalEntry {
found: line.amount.currency.code().to_string(),
});
}
match line.side {
Side::Debit => debits += line.amount.amount,
Side::Credit => credits += line.amount.amount,
}
let sides: Vec<(Side, Decimal)> = self
.lines
.iter()
.map(|l| (l.side, l.amount.amount))
.collect();
kernel::check_lines(&sides).map_err(|e| match e {
LineError::TooFewLines => LedgerError::TooFewLines,
LineError::ZeroAmount => LedgerError::ZeroAmountLine,
LineError::NegativeAmount => LedgerError::NegativeAmountLine,
LineError::OutOfRange => LedgerError::AmountOutOfRange,
LineError::Unbalanced { debits, credits } => {
LedgerError::UnbalancedEntry { debits, credits }
}
}
if debits != credits {
return Err(LedgerError::UnbalancedEntry { debits, credits });
}
Ok(())
})
}
/// Net movement per account within this entry: debits minus credits.
/// Sums to zero across accounts for any valid entry.
pub fn movements(&self) -> HashMap<AccountId, Decimal> {
let mut out = HashMap::new();
for line in &self.lines {
let delta = match line.side {
Side::Debit => line.amount.amount,
Side::Credit => -line.amount.amount,
};
*out.entry(line.account).or_insert(Decimal::ZERO) += delta;
}
out
kernel::movements(
self.lines
.iter()
.map(|l| (l.account, l.side, l.amount.amount)),
)
.expect("posted lines are in range, so their sums are")
.into_iter()
.collect()
}
}
+292
View File
@@ -0,0 +1,292 @@
//! The accounting kernel: the arithmetic every bookkeeping invariant rests
//! on, in one place.
//!
//! Each function here is small, pure, and generic over [`Amount`]. The
//! engine runs it on [`Decimal`]; the Kani harnesses in `proofs.rs` run the
//! very same code on machine integers and prove it correct for every input
//! within their bounds; and the Lean model in `verify/lean` proves the
//! laws it implements hold for books of any size. See
//! `docs/verification.md` for which proof covers which law.
//!
//! The laws, in the order they appear:
//!
//! * a line's signed amount is `+amount` for a debit, `-amount` for a credit;
//! * an entry is acceptable when it has two or more lines, every amount is
//! positive and in range, and its debits equal its credits exactly;
//! * an acceptable entry nets to zero, account by account summed;
//! * a reversal flips every side, so it is acceptable exactly when the
//! original is, and the two together net every account to zero;
//! * an account's balance is its movement read on its normal side, so the
//! balances satisfy the accounting equation whenever movements net to
//! zero;
//! * a trial balance puts each movement in exactly one column, so its
//! columns differ by the net of all movements;
//! * a closing line zeroes the account it closes;
//! * an allocation's parts sum to the whole, to the last unit.
use crate::{AccountType, Side};
use rust_decimal::Decimal;
/// What the kernel needs of a money amount: exact, ordered, and checked
/// arithmetic that reports overflow instead of rounding or wrapping.
pub trait Amount: Copy + Ord {
/// Zero.
const ZERO: Self;
/// `self + rhs`, or `None` when it cannot be represented exactly.
fn checked_add(self, rhs: Self) -> Option<Self>;
/// `-self`, or `None` when it cannot be represented.
fn checked_neg(self) -> Option<Self>;
/// True when the amount is small and precise enough for a journal line
/// (see [`MAX_LINE_AMOUNT`] and [`MAX_LINE_SCALE`] for [`Decimal`]).
fn in_line_range(self) -> bool;
/// `self - rhs`, or `None` when it cannot be represented exactly.
fn checked_sub(self, rhs: Self) -> Option<Self> {
self.checked_add(rhs.checked_neg()?)
}
}
/// The largest amount one journal line may carry: one quadrillion units of
/// the base currency. Far beyond any real transaction, and far enough
/// below [`Decimal::MAX`] that no realistic sum of lines can overflow.
pub const MAX_LINE_AMOUNT: Decimal = Decimal::from_parts(0xA4C6_8000, 0x0003_8D7E, 0, false, 0);
/// The most decimal places a journal line may carry. Cents need two;
/// foreign-currency lines (amount times rate) need more; ten keeps every
/// sum of lines exact as long as the total stays under 7.9 quintillion.
pub const MAX_LINE_SCALE: u32 = 10;
impl Amount for Decimal {
const ZERO: Self = Decimal::ZERO;
fn checked_add(self, rhs: Self) -> Option<Self> {
Decimal::checked_add(self, rhs)
}
fn checked_neg(self) -> Option<Self> {
Some(-self)
}
fn in_line_range(self) -> bool {
self.abs() <= MAX_LINE_AMOUNT && self.normalize().scale() <= MAX_LINE_SCALE
}
}
impl Amount for i64 {
const ZERO: Self = 0;
fn checked_add(self, rhs: Self) -> Option<Self> {
i64::checked_add(self, rhs)
}
fn checked_neg(self) -> Option<Self> {
i64::checked_neg(self)
}
fn in_line_range(self) -> bool {
// Scaled down with the same headroom as Decimal's range: lines
// this size can be summed many times over without overflow.
self.unsigned_abs() <= (1 << 40)
}
}
/// Why the kernel rejected an entry's lines.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LineError<T> {
/// Fewer than two lines.
TooFewLines,
/// A line with a zero amount.
ZeroAmount,
/// A line with a negative amount: direction is the side's job.
NegativeAmount,
/// A line too large or too precise for a journal line.
OutOfRange,
/// Debits and credits differ.
Unbalanced {
/// Sum of debit lines.
debits: T,
/// Sum of credit lines.
credits: T,
},
}
/// The other side.
pub fn flip(side: Side) -> Side {
match side {
Side::Debit => Side::Credit,
Side::Credit => Side::Debit,
}
}
/// A line's signed amount: positive for a debit, negative for a credit.
pub fn signed<T: Amount>(side: Side, amount: T) -> Option<T> {
match side {
Side::Debit => Some(amount),
Side::Credit => amount.checked_neg(),
}
}
/// Total debits and total credits of some lines.
pub fn totals<T: Amount>(lines: impl IntoIterator<Item = (Side, T)>) -> Option<(T, T)> {
let mut debits = T::ZERO;
let mut credits = T::ZERO;
for (side, amount) in lines {
match side {
Side::Debit => debits = debits.checked_add(amount)?,
Side::Credit => credits = credits.checked_add(amount)?,
}
}
Some((debits, credits))
}
/// The net of some lines: debits minus credits.
pub fn net<T: Amount>(lines: impl IntoIterator<Item = (Side, T)>) -> Option<T> {
let mut total = T::ZERO;
for (side, amount) in lines {
total = total.checked_add(signed(side, amount)?)?;
}
Some(total)
}
/// Check an entry's lines: two or more, every amount positive and within
/// line range, debits equal to credits. Accounts and currencies are the
/// caller's to check; this is the arithmetic.
pub fn check_lines<T: Amount>(lines: &[(Side, T)]) -> Result<(), LineError<T>> {
if lines.len() < 2 {
return Err(LineError::TooFewLines);
}
for &(_, amount) in lines {
if amount == T::ZERO {
return Err(LineError::ZeroAmount);
}
if amount < T::ZERO {
return Err(LineError::NegativeAmount);
}
if !amount.in_line_range() {
return Err(LineError::OutOfRange);
}
}
let (debits, credits) = totals(lines.iter().copied()).ok_or(LineError::OutOfRange)?;
if debits != credits {
return Err(LineError::Unbalanced { debits, credits });
}
Ok(())
}
/// Net movement per account (debits minus credits), in order of first
/// appearance.
pub fn movements<K: Copy + Eq, T: Amount>(
lines: impl IntoIterator<Item = (K, Side, T)>,
) -> Option<Vec<(K, T)>> {
let lines: Vec<_> = lines.into_iter().collect();
let Some(&(first, _, _)) = lines.first() else {
return Some(Vec::new());
};
// At most one account per line.
let mut out = vec![(first, T::ZERO); lines.len()];
let n = movements_into(&lines, &mut out)?;
out.truncate(n);
Some(out)
}
/// [`movements`] into a caller's buffer, without allocating: returns how
/// many accounts it wrote, or `None` on overflow or if `out` is too small.
pub fn movements_into<K: Copy + Eq, T: Amount>(
lines: &[(K, Side, T)],
out: &mut [(K, T)],
) -> Option<usize> {
let mut n = 0;
for &(account, side, amount) in lines {
let delta = signed(side, amount)?;
match out[..n].iter_mut().find(|(k, _)| *k == account) {
Some((_, total)) => *total = total.checked_add(delta)?,
None => {
*out.get_mut(n)? = (account, delta);
n += 1;
}
}
}
Some(n)
}
/// The lines of a reversing entry: every side flipped, accounts and
/// amounts unchanged.
pub fn reversal<K: Copy, T: Amount>(lines: &[(K, Side, T)]) -> Vec<(K, Side, T)> {
lines.iter().map(|&line| reverse_line(line)).collect()
}
/// One line of a reversing entry: the side flipped, account and amount
/// unchanged.
pub fn reverse_line<K, T: Amount>((account, side, amount): (K, Side, T)) -> (K, Side, T) {
(account, flip(side), amount)
}
/// An account's balance read on its normal side: positive is a normal
/// balance, negative a contra balance.
pub fn normal_balance<T: Amount>(account_type: AccountType, movement: T) -> Option<T> {
match account_type.normal_side() {
Side::Debit => Some(movement),
Side::Credit => movement.checked_neg(),
}
}
/// A movement in trial-balance columns: `(debit, credit)`, one of them
/// zero, both non-negative.
pub fn columns<T: Amount>(movement: T) -> Option<(T, T)> {
if movement > T::ZERO {
Some((movement, T::ZERO))
} else {
Some((T::ZERO, movement.checked_neg()?))
}
}
/// The line that zeroes an account with this movement: the opposite side,
/// for the same amount. `None` for a zero movement (nothing to close).
pub fn closing_line<T: Amount>(movement: T) -> Option<(Side, T)> {
if movement > T::ZERO {
Some((Side::Credit, movement))
} else if movement < T::ZERO {
Some((Side::Debit, movement.checked_neg()?))
} else {
None
}
}
/// The line that credits `credit` to an account (a negative credit is a
/// debit). `None` for zero. This is [`closing_line`]: crediting `c` and
/// zeroing a movement of `c` are the same line.
pub fn credit_line<T: Amount>(credit: T) -> Option<(Side, T)> {
closing_line(credit)
}
/// Make rounded parts sum to the whole: the last part becomes `whole`
/// minus the others, absorbing every unit of rounding. Returns `None` on
/// overflow, leaving `parts` unchanged.
pub fn plug_last<T: Amount>(whole: T, parts: &mut [T]) -> Option<()> {
let Some((last, rest)) = parts.split_last_mut() else {
return Some(());
};
let mut others = T::ZERO;
for &p in rest.iter() {
others = others.checked_add(p)?;
}
*last = whole.checked_sub(others)?;
Some(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn max_line_amount_is_one_quadrillion() {
assert_eq!(MAX_LINE_AMOUNT, Decimal::from(1_000_000_000_000_000u64));
assert!(MAX_LINE_AMOUNT.in_line_range());
assert!(!(MAX_LINE_AMOUNT + Decimal::ONE).in_line_range());
assert!("0.0000000001".parse::<Decimal>().unwrap().in_line_range());
assert!(!"0.00000000001".parse::<Decimal>().unwrap().in_line_range());
// Trailing zeros are not precision.
assert!("1.000000000000".parse::<Decimal>().unwrap().in_line_range());
}
#[test]
fn credit_line_sides() {
assert_eq!(credit_line(5i64), Some((Side::Credit, 5)));
assert_eq!(credit_line(-5i64), Some((Side::Debit, 5)));
assert_eq!(credit_line(0i64), None);
}
}
+35 -63
View File
@@ -13,7 +13,7 @@ use crate::{
Account, AccountId, AccountType, AgingRow, Asset, AssetId, AssetStatus, Bill, BillId,
ChartOfAccounts, Class, ClassId, Currency, Customer, CustomerId, DocumentLine, DocumentStatus,
EntryId, EntryStatus, Invoice, InvoiceId, JournalEntry, JournalLine, LedgerError, Money,
NewAsset, Period, PeriodId, PeriodStatus, Side, Vendor, VendorId, arap, assets,
NewAsset, Period, PeriodId, PeriodStatus, Side, Vendor, VendorId, arap, assets, kernel,
payroll::{
PayrollAccounts, PayrollLine, PayrollRun, PayrollRunId, payroll_entry_plan,
payroll_remit_plan, validate_payroll_lines,
@@ -556,7 +556,9 @@ impl Ledger {
found: foreign_currency.code().to_string(),
});
}
let base_amount = foreign_amount * fx_rate;
let base_amount = foreign_amount
.checked_mul(fx_rate)
.ok_or(LedgerError::AmountOutOfRange)?;
if base_amount <= Decimal::ZERO {
return Err(LedgerError::InvalidFxRate(fx_rate));
}
@@ -622,11 +624,7 @@ impl Ledger {
}
let id = self.create_entry(date, description);
for (idx, line) in original.lines().iter().enumerate() {
let flipped = match line.side {
Side::Debit => Side::Credit,
Side::Credit => Side::Debit,
};
self.add_line(id, line.account, flipped, line.amount)?;
self.add_line(id, line.account, kernel::flip(line.side), line.amount)?;
// The reversal offsets the original line for line: carry the
// class so divisional P&L nets correctly.
if line.class_id.is_some() {
@@ -896,23 +894,15 @@ impl Ledger {
} else {
let entry = self.create_entry(period.end, &format!("Close period {}", period.name));
let post = |this: &mut Self, account, credit: Decimal| -> Result<(), LedgerError> {
if credit.is_zero() {
return Ok(());
match kernel::credit_line(credit) {
Some((side, amount)) => {
this.add_line(entry, account, side, Money::new(amount, this.base_currency))
}
None => Ok(()),
}
let (side, amount) = if credit > Decimal::ZERO {
(Side::Credit, credit)
} else {
(Side::Debit, -credit)
};
this.add_line(entry, account, side, Money::new(amount, this.base_currency))
};
for (account, side, amount) in closings {
let credit = if side == Side::Credit {
amount
} else {
-amount
};
post(self, account, credit)?;
self.add_line(entry, account, side, Money::new(amount, self.base_currency))?;
}
for (account, share) in equity {
post(self, account, share)?;
@@ -947,24 +937,15 @@ impl Ledger {
) {
continue;
}
let movement = self.movement_through(account.id, end);
if movement.is_zero() {
continue;
}
// To zero the account: post the opposite of its net movement.
closings.push(if movement > Decimal::ZERO {
(account.id, Side::Credit, movement)
} else {
(account.id, Side::Debit, -movement)
});
if let Some((side, amount)) =
kernel::closing_line(self.movement_through(account.id, end))
{
closings.push((account.id, side, amount));
}
}
let net = closings
.iter()
.map(|(_, side, amount)| match side {
Side::Debit => *amount,
Side::Credit => -*amount,
})
.sum();
let net = kernel::net(closings.iter().map(|&(_, side, amount)| (side, amount)))
.expect("posted lines are in range, so their sums are");
(closings, net)
}
@@ -1025,16 +1006,20 @@ impl Ledger {
/// Debits minus credits on an account across posted entries dated on or
/// before `end`.
fn movement_through(&self, account: AccountId, end: NaiveDate) -> Decimal {
let mut total = Decimal::ZERO;
for entry in self.posted_entries().filter(|e| e.date() <= end) {
for line in entry.lines().iter().filter(|l| l.account == account) {
match line.side {
Side::Debit => total += line.amount.amount,
Side::Credit => total -= line.amount.amount,
}
}
}
total
self.net_of(account, |e| e.date() <= end)
}
/// Debits minus credits on an account across the posted entries
/// `include` accepts.
fn net_of(&self, account: AccountId, include: impl Fn(&JournalEntry) -> bool) -> Decimal {
kernel::net(
self.posted_entries()
.filter(|e| include(e))
.flat_map(|e| e.lines())
.filter(|l| l.account == account)
.map(|l| (l.side, l.amount.amount)),
)
.expect("posted lines are in range, so their sums are")
}
/// [`Self::preview_close_period_to`] closing into Retained Earnings.
@@ -1712,18 +1697,7 @@ impl Ledger {
/// Signed net movement of an account across all posted entries:
/// debits minus credits. Positive means net debit movement.
pub fn account_movement(&self, account: AccountId) -> Decimal {
let mut total = Decimal::ZERO;
for entry in self.posted_entries() {
for line in entry.lines() {
if line.account == account {
match line.side {
Side::Debit => total += line.amount.amount,
Side::Credit => total -= line.amount.amount,
}
}
}
}
total
self.net_of(account, |_| true)
}
/// Balance expressed in the account's normal side: positive means a
@@ -1734,10 +1708,8 @@ impl Ledger {
.get(account)
.ok_or(LedgerError::UnknownAccount(account))?;
let movement = self.account_movement(account.id);
let balance = match account.account_type.normal_side() {
Side::Debit => movement,
Side::Credit => -movement,
};
let balance = kernel::normal_balance(account.account_type, movement)
.expect("Decimal negation is exact");
// Never leak negative zero into reports: it is a representation
// artifact of negating an exact zero, and money must never print "-0".
Ok(if balance.is_zero() {
+3
View File
@@ -24,11 +24,14 @@ pub mod classes;
pub mod error;
pub mod form1099;
pub mod journal;
pub mod kernel;
pub mod ledger;
pub mod money;
pub mod payouts;
pub mod payroll;
pub mod periods;
#[cfg(kani)]
mod proofs;
pub mod reports;
pub mod schedules;
pub mod tax;
+1 -3
View File
@@ -77,9 +77,7 @@ pub fn allocate(
.iter()
.map(|s| (amount * s).round_dp_with_strategy(2, MidpointAwayFromZero))
.collect();
if let Some((last, rest)) = parts.split_last_mut() {
*last = amount - rest.iter().copied().sum::<rust_decimal::Decimal>();
}
crate::kernel::plug_last(amount, &mut parts).expect("allocated amounts are in range");
parts
}
+485
View File
@@ -0,0 +1,485 @@
//! Kani proof harnesses for the accounting kernel.
//!
//! `cargo kani -p ledger-core` (or `just prove`) model-checks each
//! harness: CBMC explores *every* input within the stated bounds, not a
//! sample, and fails if any one of them breaks an assertion, panics, or
//! overflows. The code under proof is the engine's own [`crate::kernel`],
//! instantiated at `i64` (exact integer money, in the smallest unit); the
//! engine runs the same functions on `Decimal`, and the `kernel_bridge`
//! property test checks the two instantiations agree.
//!
//! Bounds: entries of up to [`N`] lines over up to [`K`] accounts, and
//! amounts up to 2^40 in magnitude. The Lean model in `verify/lean`
//! proves the same laws for any number of lines and accounts.
use crate::kernel::{self, Amount, LineError};
use crate::{AccountType, Side};
/// Most lines per entry the harnesses explore.
const N: usize = 4;
/// Accounts the harnesses' lines draw from.
const K: u8 = 3;
fn any_side() -> Side {
if kani::any() {
Side::Debit
} else {
Side::Credit
}
}
fn any_type() -> AccountType {
// An assumption, not `% 5`: modulo is costly for the SAT solver.
let t: u8 = kani::any();
kani::assume(t < 5);
match t {
0 => AccountType::Asset,
1 => AccountType::Liability,
2 => AccountType::Equity,
3 => AccountType::Revenue,
_ => AccountType::Expense,
}
}
/// Any amount a journal line could carry, sign included.
fn any_amount() -> i64 {
let a: i64 = kani::any();
kani::assume(a.in_line_range());
a
}
/// Any count up to `N`. Harnesses fill fixed arrays and take a prefix of
/// this length: fixed arrays keep the model free of allocation (which is
/// also why they call `movements_into` rather than `movements`).
fn any_len() -> usize {
let n: usize = kani::any();
kani::assume(n <= N);
n
}
/// `N` lines, any sides, any in-range amounts (use a prefix).
fn any_lines() -> [(Side, i64); N] {
core::array::from_fn(|_| (any_side(), any_amount()))
}
/// `N` lines over `K` accounts (use a prefix).
fn any_account_lines() -> [(u8, Side, i64); N] {
core::array::from_fn(|_| {
let k: u8 = kani::any();
kani::assume(k < K);
(k, any_side(), any_amount())
})
}
/// The mathematical signed amount. Amounts are at most 2^40 and no
/// harness sums more than eight, so `i64` holds every spec sum exactly
/// (and Kani would flag it if not).
fn wide(side: Side, amount: i64) -> i64 {
match side {
Side::Debit => amount,
Side::Credit => -(amount),
}
}
fn wide_net(lines: &[(Side, i64)]) -> i64 {
lines.iter().map(|&(s, a)| wide(s, a)).sum()
}
/// The sides and amounts of some account lines, into a fixed array.
fn sides(lines: &[(u8, Side, i64)]) -> [(Side, i64); N] {
let mut out = [(Side::Debit, 0); N];
for (o, &(_, s, a)) in out.iter_mut().zip(lines) {
*o = (s, a);
}
out
}
// ---------------------------------------------------------------------------
// Lines and entries
// ---------------------------------------------------------------------------
/// Flipping a side negates its signed amount, and flipping twice is the
/// identity.
#[kani::proof]
fn flip_negates() {
let side = any_side();
let a = any_amount();
assert_eq!(kernel::flip(kernel::flip(side)), side);
assert_ne!(kernel::flip(side), side);
assert_eq!(kernel::signed(kernel::flip(side), a), Some(-wide(side, a)));
}
/// In-range lines never overflow the kernel's sums: `totals` and `net`
/// always succeed, and agree with the mathematical sums. (`movements`
/// is covered by its own harnesses.)
#[kani::proof]
#[kani::unwind(6)]
fn sums_are_exact() {
let all = any_account_lines();
let lines = &all[..any_len()];
let all_sides = sides(lines);
let s = &all_sides[..lines.len()];
let (debits, credits) = kernel::totals(s.iter().copied()).expect("no overflow");
let net = kernel::net(s.iter().copied()).expect("no overflow");
assert_eq!(net, wide_net(s));
assert_eq!(debits - credits, wide_net(s));
}
/// The kernel accepts an entry exactly when it has two or more lines,
/// every amount is positive and in range, and debits equal credits.
#[kani::proof]
#[kani::unwind(6)]
fn check_lines_is_exact() {
let all = any_lines();
let lines = &all[..any_len()];
let positive = lines.iter().all(|&(_, a)| a > 0);
let debits: i64 = lines
.iter()
.filter(|l| l.0 == Side::Debit)
.map(|l| l.1)
.sum();
let credits: i64 = lines
.iter()
.filter(|l| l.0 == Side::Credit)
.map(|l| l.1)
.sum();
let spec = lines.len() >= 2 && positive && debits == credits;
let got = kernel::check_lines(lines);
assert_eq!(got.is_ok(), spec);
if let Err(LineError::Unbalanced {
debits: d,
credits: c,
}) = got
{
assert_eq!((d, c), (debits, credits));
}
}
/// An out-of-range amount is always rejected.
#[kani::proof]
fn out_of_range_is_rejected() {
let big: i64 = kani::any();
kani::assume(big > 0 && !big.in_line_range());
let side = any_side();
let lines = [(side, big), (kernel::flip(side), big)];
assert_eq!(kernel::check_lines(&lines), Err(LineError::OutOfRange));
}
/// An accepted entry nets to zero.
#[kani::proof]
#[kani::unwind(6)]
fn accepted_entry_nets_to_zero() {
let all = any_lines();
let lines = &all[..any_len()];
if kernel::check_lines(lines).is_ok() {
assert_eq!(kernel::net(lines.iter().copied()), Some(0));
kani::cover!(lines.len() == N, "an accepted four-line entry");
}
}
/// Movements group lines by account without losing or inventing money:
/// any account appears at most once, and its movement is the net of its
/// own lines (zero if it has none).
#[kani::proof]
#[kani::unwind(6)]
fn movements_are_per_account() {
let all = any_account_lines();
let lines = &all[..any_len()];
let mut out = [(0u8, 0i64); N];
let n = kernel::movements_into(lines, &mut out).expect("no overflow, room for all");
let moves = &out[..n];
let k: u8 = kani::any();
kani::assume(k < K);
let mut own = 0i64;
let mut has = false;
for &(a, s, x) in lines {
if a == k {
own += wide(s, x);
has = true;
}
}
let mut found = 0;
let mut movement = 0i64;
for &(a, m) in moves {
if a == k {
found += 1;
movement = m;
}
}
assert_eq!(found, usize::from(has));
assert_eq!(movement, own);
kani::cover!(n == 3, "three distinct accounts");
}
/// The movements sum to the lines' net: zero for an accepted entry.
#[kani::proof]
#[kani::unwind(6)]
fn movements_sum_to_the_net() {
let all = any_account_lines();
let lines = &all[..any_len()];
let mut out = [(0u8, 0i64); N];
let n = kernel::movements_into(lines, &mut out).expect("no overflow, room for all");
let total: i64 = out[..n].iter().map(|&(_, m)| m).sum();
let all_sides = sides(lines);
let s = &all_sides[..lines.len()];
assert_eq!(total, wide_net(s));
if kernel::check_lines(s).is_ok() {
assert_eq!(total, 0);
kani::cover!(
n >= 2 && lines.len() == N,
"an accepted entry over several accounts"
);
}
}
/// A reversal flips every side and keeps every account and amount, so it
/// is accepted exactly when the original is. (`kernel::reversal` is
/// `reverse_line` mapped over the lines; proving it line by line keeps
/// the model free of allocation.)
#[kani::proof]
#[kani::unwind(6)]
fn reversal_mirrors() {
let all = any_account_lines();
let lines = &all[..any_len()];
let mut rev = [(0u8, Side::Debit, 0i64); N];
for i in 0..lines.len() {
rev[i] = kernel::reverse_line(lines[i]);
assert_eq!((rev[i].0, rev[i].2), (lines[i].0, lines[i].2));
assert_eq!(rev[i].1, kernel::flip(lines[i].1));
}
let n = lines.len();
assert_eq!(
kernel::check_lines(&sides(&rev[..n])[..n]).is_ok(),
kernel::check_lines(&sides(lines)[..n]).is_ok()
);
kani::cover!(
n == N && kernel::check_lines(&sides(lines)[..n]).is_ok(),
"an accepted four-line entry, reversed"
);
}
/// An entry and its reversal together net every account to zero.
#[kani::proof]
#[kani::unwind(10)]
fn reversal_cancels() {
let all = any_account_lines();
let n = any_len();
let lines = &all[..n];
// Element by element, never `copy_from_slice`: Kani 0.68 mis-models a
// bulk copy of symbolic length (see `kani_symbolic_copy_canary`).
let mut both = [(0u8, Side::Debit, 0i64); 2 * N];
for i in 0..n {
let (k, s, a) = lines[i];
both[i] = lines[i];
both[n + i] = kernel::reverse_line((k, s, a));
}
let mut out = [(0u8, 0i64); 2 * N];
let m = kernel::movements_into(&both[..2 * n], &mut out).expect("no overflow");
for &(_, total) in &out[..m] {
assert_eq!(total, 0);
}
// Not vacuous: an account with several lines on both sides is reached.
kani::cover!(n == N && m == 1, "four lines on one account, reversed");
}
// ---------------------------------------------------------------------------
// Balances and statements
// ---------------------------------------------------------------------------
/// A normal balance is the movement for assets and expenses, and its
/// negation for liabilities, equity, and revenue. This is the fact the
/// accounting equation rests on: the Lean theorem `accounting_equation`
/// proves it gives Assets = Liabilities + Equity + (Revenue − Expenses)
/// for any number of accounts whose movements net to zero.
#[kani::proof]
fn normal_balance_is_signed() {
let t = any_type();
let m = any_amount();
let b = kernel::normal_balance(t, m).expect("in range");
match t {
AccountType::Asset | AccountType::Expense => assert_eq!(b, m),
AccountType::Liability | AccountType::Equity | AccountType::Revenue => {
assert_eq!(b, -m)
}
}
}
/// The accounting equation end to end on the code, for up to three
/// accounts: whenever movements net to zero,
/// Assets = Liabilities + Equity + (Revenue − Expenses). (Regrouping a
/// sum of signed 64-bit values is hard for a SAT solver, so the bound is
/// small here; `normal_balance_is_signed` and the Lean proof cover the
/// rest.)
#[kani::proof]
#[kani::unwind(4)]
fn accounting_equation() {
const EQ: usize = 3;
let all: [(AccountType, i64); EQ] = core::array::from_fn(|_| (any_type(), any_amount()));
let n: usize = kani::any();
kani::assume(n <= EQ);
let accounts = &all[..n];
let (mut net, mut a, mut l, mut e, mut r, mut x) = (0i64, 0i64, 0i64, 0i64, 0i64, 0i64);
for &(t, m) in accounts {
net += m;
let b = kernel::normal_balance(t, m).expect("in range");
match t {
AccountType::Asset => a += b,
AccountType::Liability => l += b,
AccountType::Equity => e += b,
AccountType::Revenue => r += b,
AccountType::Expense => x += b,
}
}
assert_eq!(a - (l + e + (r - x)), net);
if net == 0 {
assert_eq!(a, l + e + (r - x));
kani::cover!(n == EQ, "a balanced three-account chart");
}
}
/// A normal-side line raises an account's balance by its amount; an
/// opposite-side line lowers it.
#[kani::proof]
fn normal_side_increases() {
let t = any_type();
let a = any_amount();
let side = any_side();
let movement = kernel::signed(side, a).expect("in range");
let bal = kernel::normal_balance(t, movement).expect("in range");
if side == t.normal_side() {
assert_eq!(bal, a);
} else {
assert_eq!(bal, -a);
}
}
/// A trial balance puts each movement in exactly one non-negative column,
/// so its columns balance exactly when movements net to zero.
#[kani::proof]
#[kani::unwind(6)]
fn trial_balance_columns() {
let all: [i64; N] = core::array::from_fn(|_| any_amount());
let moves = &all[..any_len()];
let (mut debits, mut credits) = (0i64, 0i64);
for &m in moves {
let (d, c) = kernel::columns(m).expect("in range");
assert!(d >= 0 && c >= 0);
assert!(d == 0 || c == 0);
assert_eq!(d - c, m);
debits += d;
credits += c;
}
let net: i64 = moves.iter().map(|&m| m).sum();
assert_eq!(debits - credits, net);
assert_eq!(debits == credits, net == 0);
}
// ---------------------------------------------------------------------------
// Closing
// ---------------------------------------------------------------------------
/// A closing line zeroes its account: opposite side, same amount, nothing
/// for a zero balance.
#[kani::proof]
fn closing_line_zeroes() {
let m = any_amount();
match kernel::closing_line(m) {
None => assert_eq!(m, 0),
Some((side, amount)) => {
assert!(amount > 0);
assert_eq!(m + wide(side, amount), 0);
}
}
}
/// `credit_line` credits exactly what it is asked to.
#[kani::proof]
fn credit_line_credits() {
let c = any_amount();
match kernel::credit_line(c) {
None => assert_eq!(c, 0),
Some((side, amount)) => {
assert!(amount > 0);
assert_eq!(wide(side, amount), -c);
}
}
}
/// A period close balances and zeroes every revenue and expense account:
/// one closing line per nonzero nominal account, plus the net credited to
/// equity (whole, or split among members): the entry's lines net to
/// zero, and each nominal account ends at zero.
#[kani::proof]
#[kani::unwind(6)]
fn closing_entry_balances() {
const NOMINAL: usize = 3;
let nominal: [i64; NOMINAL] = core::array::from_fn(|_| any_amount());
// Each nominal account's closing line zeroes it.
let mut entry = [(Side::Debit, 0i64); NOMINAL + 2];
let mut len = 0;
for &m in &nominal {
if let Some((side, amount)) = kernel::closing_line(m) {
assert_eq!(m + wide(side, amount), 0);
entry[len] = (side, amount);
len += 1;
} else {
assert_eq!(m, 0);
}
}
let net = kernel::net(entry[..len].iter().copied()).expect("no overflow");
// Split the net between two members, the first share rounded any way.
let mut parts = [any_amount(), 0];
kernel::plug_last(net, &mut parts).expect("no overflow");
for p in parts {
if let Some(line) = kernel::credit_line(p) {
entry[len] = line;
len += 1;
}
}
assert_eq!(wide_net(&entry[..len]), 0);
kani::cover!(len == NOMINAL + 2, "three accounts closed to two members");
}
/// An allocation's parts sum to the whole exactly, whatever the rounding
/// of the others, and only the last part changes.
#[kani::proof]
#[kani::unwind(6)]
fn plug_last_sums_exactly() {
let whole = any_amount();
let n = any_len();
let mut all: [i64; N] = core::array::from_fn(|_| any_amount());
let before = all;
let parts = &mut all[..n];
kernel::plug_last(whole, parts).expect("no overflow");
if n > 0 {
let sum: i64 = parts.iter().map(|&p| p).sum();
assert_eq!(sum, whole);
for i in 0..n - 1 {
assert_eq!(parts[i], before[i]);
}
}
}
// ---------------------------------------------------------------------------
// The checker itself
// ---------------------------------------------------------------------------
/// A canary for a known Kani bug: Kani 0.68 (CBMC 6.11) mis-models a bulk
/// copy whose length is symbolic, so this correct copy "fails"
/// (model-checking/kani#4904, closed as a duplicate, unfixed in 0.68). The
/// bug is unsound in both directions, so no harness above copies a slice
/// of symbolic length. This harness is expected to fail; when a Kani
/// upgrade fixes the bug it will start passing, `should_panic` will then
/// report it, and this canary and the comment in `reversal_cancels` can go.
#[kani::proof]
#[kani::unwind(10)]
#[kani::should_panic]
fn kani_symbolic_copy_canary() {
let src: [i64; N] = kani::any();
let n = any_len();
let mut dst = [0i64; 2 * N];
dst[..n].copy_from_slice(&src[..n]);
for i in 0..n {
assert_eq!(dst[i], src[i]);
}
}
+3 -9
View File
@@ -59,10 +59,8 @@ fn trial_balance_where(ledger: &Ledger, include: &dyn Fn(&JournalEntry) -> bool)
let mut movements: BTreeMap<crate::AccountId, Decimal> = BTreeMap::new();
for entry in ledger.posted_entries().filter(|e| include(e)) {
for line in entry.lines() {
let signed = match line.side {
crate::Side::Debit => line.amount.amount,
crate::Side::Credit => -line.amount.amount,
};
let signed = crate::kernel::signed(line.side, line.amount.amount)
.expect("Decimal negation is exact");
*movements.entry(line.account).or_default() += signed;
}
}
@@ -80,11 +78,7 @@ fn trial_balance_where(ledger: &Ledger, include: &dyn Fn(&JournalEntry) -> bool)
if movement.is_zero() {
continue;
}
let (debit, credit) = if movement > Decimal::ZERO {
(movement, Decimal::ZERO)
} else {
(Decimal::ZERO, -movement)
};
let (debit, credit) = crate::kernel::columns(movement).expect("Decimal negation is exact");
total_debits += debit;
total_credits += credit;
lines.push(TrialBalanceLine {
+59
View File
@@ -181,6 +181,65 @@ fn zero_amount_line_is_rejected() {
);
}
#[test]
fn negative_amount_line_is_rejected() {
let c = fixture_company();
let mut ledger = c.ledger;
let id = ledger.create_entry(date(21), "Negative line");
let minus = Money::new(Decimal::from(-100), Currency::USD);
ledger.add_line(id, c.cash, Side::Debit, minus).unwrap();
ledger.add_line(id, c.equity, Side::Credit, minus).unwrap();
assert_eq!(
ledger.post_entry(id).unwrap_err(),
LedgerError::NegativeAmountLine
);
}
#[test]
fn out_of_range_amount_is_rejected() {
let c = fixture_company();
let mut ledger = c.ledger;
// Big enough to overflow a sum: an error, never a panic.
let id = ledger.create_entry(date(21), "Too big");
let big = Money::new(Decimal::MAX, Currency::USD);
ledger.add_line(id, c.cash, Side::Debit, big).unwrap();
ledger.add_line(id, c.cash, Side::Debit, big).unwrap();
ledger.add_line(id, c.equity, Side::Credit, big).unwrap();
assert_eq!(
ledger.post_entry(id).unwrap_err(),
LedgerError::AmountOutOfRange
);
// Too precise.
let id = ledger.create_entry(date(21), "Too precise");
let tiny = Money::new("0.00000000001".parse().unwrap(), Currency::USD);
ledger.add_line(id, c.cash, Side::Debit, tiny).unwrap();
ledger.add_line(id, c.equity, Side::Credit, tiny).unwrap();
assert_eq!(
ledger.post_entry(id).unwrap_err(),
LedgerError::AmountOutOfRange
);
}
#[test]
fn fx_overflow_is_an_error() {
let c = fixture_company();
let mut ledger = c.ledger;
let id = ledger.create_entry(date(21), "Huge FX");
assert_eq!(
ledger
.add_line_fx(
id,
c.cash,
Side::Debit,
Currency::EUR,
Decimal::MAX,
Decimal::from(2)
)
.unwrap_err(),
LedgerError::AmountOutOfRange
);
}
#[test]
fn unknown_account_is_rejected() {
let c = fixture_company();
+154
View File
@@ -0,0 +1,154 @@
//! The bridge between the proofs and the engine.
//!
//! The Kani harnesses (`src/proofs.rs`) prove the accounting kernel on
//! `i64` money in the smallest unit; the engine runs the same kernel on
//! `Decimal`. These properties check the two instantiations agree, value
//! for value, on cents: whatever Kani proved of the `i64` kernel holds of
//! the `Decimal` one for the same amounts.
use ledger_core::kernel::{self, Amount, LineError};
use ledger_core::{AccountType, Side};
use proptest::prelude::*;
use rust_decimal::Decimal;
/// Cents within both instantiations' line range.
const MAX_CENTS: i64 = 1 << 40;
fn dec(cents: i64) -> Decimal {
Decimal::new(cents, 2)
}
fn arb_side() -> impl Strategy<Value = Side> {
prop_oneof![Just(Side::Debit), Just(Side::Credit)]
}
fn arb_type() -> impl Strategy<Value = AccountType> {
prop_oneof![
Just(AccountType::Asset),
Just(AccountType::Liability),
Just(AccountType::Equity),
Just(AccountType::Revenue),
Just(AccountType::Expense),
]
}
fn arb_cents() -> impl Strategy<Value = i64> {
prop_oneof![
-MAX_CENTS..=MAX_CENTS,
// Small amounts collide, so balanced entries and zeros show up.
-3i64..=3,
]
}
/// Lines over four accounts. Balanced entries are rare at random, so half
/// the time the last line plugs the difference.
fn arb_lines() -> impl Strategy<Value = Vec<(u8, Side, i64)>> {
(
prop::collection::vec((0u8..4, arb_side(), arb_cents()), 0..7),
any::<bool>(),
)
.prop_map(|(mut lines, plug)| {
if plug && !lines.is_empty() {
let net: i64 = lines[..lines.len() - 1]
.iter()
.map(|&(_, s, a)| if s == Side::Debit { a } else { -a })
.sum();
let last = lines.len() - 1;
if net.abs() > MAX_CENTS {
// Out of both ranges, where the two differ by design.
return lines;
}
lines[last].1 = if net > 0 { Side::Credit } else { Side::Debit };
lines[last].2 = net.abs();
}
lines
})
}
fn to_dec(lines: &[(u8, Side, i64)]) -> Vec<(u8, Side, Decimal)> {
lines.iter().map(|&(k, s, a)| (k, s, dec(a))).collect()
}
fn sides<T: Copy>(lines: &[(u8, Side, T)]) -> Vec<(Side, T)> {
lines.iter().map(|&(_, s, a)| (s, a)).collect()
}
fn map_err(e: LineError<i64>) -> LineError<Decimal> {
match e {
LineError::TooFewLines => LineError::TooFewLines,
LineError::ZeroAmount => LineError::ZeroAmount,
LineError::NegativeAmount => LineError::NegativeAmount,
LineError::OutOfRange => LineError::OutOfRange,
LineError::Unbalanced { debits, credits } => LineError::Unbalanced {
debits: dec(debits),
credits: dec(credits),
},
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(2000))]
#[test]
fn check_lines_agrees(lines in arb_lines()) {
let i = kernel::check_lines(&sides(&lines)).map_err(map_err);
let d = kernel::check_lines(&sides(&to_dec(&lines)));
prop_assert_eq!(i, d);
}
#[test]
fn sums_agree(lines in arb_lines()) {
let (i, d) = (sides(&lines), sides(&to_dec(&lines)));
let ti = kernel::totals(i.iter().copied()).unwrap();
let td = kernel::totals(d.iter().copied()).unwrap();
prop_assert_eq!((dec(ti.0), dec(ti.1)), td);
prop_assert_eq!(
kernel::net(i.iter().copied()).map(dec),
kernel::net(d.iter().copied())
);
let mi: Vec<_> = kernel::movements(lines.iter().copied())
.unwrap()
.into_iter()
.map(|(k, m)| (k, dec(m)))
.collect();
prop_assert_eq!(Some(mi), kernel::movements(to_dec(&lines)));
}
#[test]
fn reversal_agrees(lines in arb_lines()) {
let ri: Vec<_> = kernel::reversal(&lines)
.into_iter()
.map(|(k, s, a)| (k, s, dec(a)))
.collect();
prop_assert_eq!(ri, kernel::reversal(&to_dec(&lines)));
}
#[test]
fn balances_agree(t in arb_type(), m in arb_cents()) {
prop_assert_eq!(kernel::normal_balance(t, m).map(dec), kernel::normal_balance(t, dec(m)));
let ci = kernel::columns(m).unwrap();
prop_assert_eq!(Some((dec(ci.0), dec(ci.1))), kernel::columns(dec(m)));
prop_assert_eq!(
kernel::closing_line(m).map(|(s, a)| (s, dec(a))),
kernel::closing_line(dec(m))
);
prop_assert_eq!(
kernel::credit_line(m).map(|(s, a)| (s, dec(a))),
kernel::credit_line(dec(m))
);
}
#[test]
fn plug_last_agrees(whole in arb_cents(), parts in prop::collection::vec(arb_cents(), 0..5)) {
let mut pi = parts.clone();
let mut pd: Vec<Decimal> = parts.iter().copied().map(dec).collect();
kernel::plug_last(whole, &mut pi).unwrap();
kernel::plug_last(dec(whole), &mut pd).unwrap();
prop_assert_eq!(pi.into_iter().map(dec).collect::<Vec<_>>(), pd);
}
#[test]
fn line_range_agrees(cents in arb_cents()) {
prop_assert_eq!(cents.in_line_range(), dec(cents).in_line_range());
}
}