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
739 lines
25 KiB
Rust
739 lines
25 KiB
Rust
//! 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]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Most parts the apportionment harnesses explore (its ranking is
|
||
/// quadratic, so the bound is smaller than `N`).
|
||
const AP: usize = 3;
|
||
|
||
/// Inputs for `kernel::apportion`: rounded-down parts, remainders, a unit,
|
||
/// and a whole some shortfall above the parts' sum.
|
||
fn any_apportionment() -> (usize, [i64; AP], [i64; AP], i64, i64, i64) {
|
||
let n: usize = kani::any();
|
||
kani::assume(n <= AP);
|
||
let before: [i64; AP] = core::array::from_fn(|_| any_amount());
|
||
let rems: [i64; AP] = core::array::from_fn(|_| any_amount());
|
||
let unit: i64 = kani::any();
|
||
kani::assume(unit > 0 && unit <= 1000);
|
||
let shortfall: i64 = kani::any();
|
||
kani::assume(shortfall.unsigned_abs() <= 4000);
|
||
let mut whole = shortfall;
|
||
for &p in &before[..n] {
|
||
whole += p;
|
||
}
|
||
(n, before, rems, unit, shortfall, whole)
|
||
}
|
||
|
||
/// `kernel::apportion` succeeds exactly when the parts are short of the
|
||
/// whole by less than one unit per part; then the parts sum to the whole,
|
||
/// and each gains nothing or one unit, except that one part may also take
|
||
/// the sub-unit fraction.
|
||
#[kani::proof]
|
||
#[kani::unwind(5)]
|
||
fn apportion_is_exact() {
|
||
let (n, before, rems, unit, shortfall, whole) = any_apportionment();
|
||
let mut parts = before;
|
||
let ok = kernel::apportion(whole, &mut parts[..n], &rems[..n], unit);
|
||
let in_range = if n == 0 {
|
||
whole == 0
|
||
} else {
|
||
shortfall >= 0 && shortfall < n as i64 * unit
|
||
};
|
||
assert_eq!(ok.is_some(), in_range);
|
||
if ok.is_none() {
|
||
return;
|
||
}
|
||
let mut sum = 0i64;
|
||
let mut fraction_takers = 0;
|
||
for i in 0..n {
|
||
sum += parts[i];
|
||
let gain = parts[i] - before[i];
|
||
assert!(gain >= 0 && gain < 2 * unit);
|
||
if gain != 0 && gain != unit {
|
||
fraction_takers += 1;
|
||
}
|
||
}
|
||
assert_eq!(sum, whole);
|
||
assert!(fraction_takers <= 1);
|
||
kani::cover!(
|
||
n == AP && shortfall >= 2 * unit,
|
||
"three parts, two units short"
|
||
);
|
||
}
|
||
|
||
/// `kernel::apportion` never gives a unit to a part while a part with a
|
||
/// strictly larger remainder goes without (owner, 2026-10-13: no member
|
||
/// favored by position).
|
||
#[kani::proof]
|
||
#[kani::unwind(5)]
|
||
fn apportion_is_fair() {
|
||
let (n, before, rems, unit, _, whole) = any_apportionment();
|
||
let mut parts = before;
|
||
if kernel::apportion(whole, &mut parts[..n], &rems[..n], unit).is_none() {
|
||
return;
|
||
}
|
||
for i in 0..n {
|
||
for j in 0..n {
|
||
if rems[i] > rems[j] {
|
||
assert!(parts[i] - before[i] >= unit || parts[j] - before[j] < unit);
|
||
}
|
||
}
|
||
}
|
||
kani::cover!(
|
||
n == AP && rems[0] < rems[2],
|
||
"a later part with a larger remainder"
|
||
);
|
||
}
|
||
|
||
/// `kernel::apportion_cumulative` is `kernel::apportion` less what was
|
||
/// given: it succeeds exactly when `apportion` would on the same inputs and
|
||
/// the running total is in line range (the parts, unit and `given` are,
|
||
/// here), and then each part is apportion's less its `given`, so each
|
||
/// running total (given + part) is apportion's. With `apportion_is_exact`,
|
||
/// the parts sum to the running total less what was given, and each
|
||
/// running total is nothing or one unit above its rounded-down part (one
|
||
/// of them may also take the sub-unit fraction). A refusal leaves the
|
||
/// parts as they were.
|
||
#[kani::proof]
|
||
#[kani::unwind(5)]
|
||
fn apportion_cumulative_is_exact() {
|
||
let (n, before, rems, unit, _, whole) = any_apportionment();
|
||
let given: [i64; AP] = core::array::from_fn(|_| any_amount());
|
||
let mut reference = before;
|
||
let apportioned = kernel::apportion(whole, &mut reference[..n], &rems[..n], unit);
|
||
let mut parts = before;
|
||
let ok = kernel::apportion_cumulative(whole, &mut parts[..n], &rems[..n], unit, &given[..n]);
|
||
assert_eq!(ok.is_some(), apportioned.is_some() && whole.in_line_range());
|
||
let mut against = false;
|
||
for i in 0..n {
|
||
if ok.is_some() {
|
||
assert_eq!(parts[i] + given[i], reference[i]);
|
||
against |= parts[i] < 0;
|
||
} else {
|
||
assert_eq!(parts[i], before[i]);
|
||
}
|
||
}
|
||
kani::cover!(
|
||
ok.is_some() && n == AP && against && whole > given[0] + given[1] + given[2],
|
||
"a true-up against the net's sign"
|
||
);
|
||
}
|
||
|
||
/// Bound on the amounts the drift harnesses explore (symbolic multiply and
|
||
/// divide are costly for the solver; Lean's `allocW_drift` and
|
||
/// `cumulative_drift` carry any weights and any amounts).
|
||
const DRIFT: i64 = 1 << 12;
|
||
|
||
/// **Bounded drift** (rules.md B-13) for members with these integer
|
||
/// weights (shares w_i / W, W their sum): a cumulative members close,
|
||
/// modelled as `periods::allocate_cumulative` runs it at whole minor units
|
||
/// (unit 1), hands out exactly its net, and leaves every member's running
|
||
/// total, given + part, within one unit of their exact share of
|
||
/// everything closed, whatever earlier closes gave them (any `given`
|
||
/// summing to what they closed: legacy rounding included). Scaled by W:
|
||
/// |(given_i + part_i)·W − T·w_i| < W.
|
||
fn drift_is_under_a_unit(weights: &[i64]) {
|
||
let n = weights.len();
|
||
let mut total_weight = 0i64;
|
||
for w in weights {
|
||
total_weight += w;
|
||
}
|
||
let closed: i64 = kani::any();
|
||
let net: i64 = kani::any();
|
||
kani::assume((-DRIFT..=DRIFT).contains(&closed) && (-DRIFT..=DRIFT).contains(&net));
|
||
// What earlier closes gave: anything, as long as it adds up.
|
||
let mut given = [0i64; AP];
|
||
let mut others = 0i64;
|
||
for g in given.iter_mut().take(n - 1) {
|
||
*g = kani::any();
|
||
kani::assume((-DRIFT..=DRIFT).contains(g));
|
||
others += *g;
|
||
}
|
||
given[n - 1] = closed - others;
|
||
// allocate_cumulative: |T| split by exact share, rounded toward zero,
|
||
// the remainders ranked; a loss runs on |T| against given negated.
|
||
let total = closed + net;
|
||
let magnitude = total.abs();
|
||
let loss = total < 0;
|
||
let mut parts = [0i64; AP];
|
||
let mut rems = [0i64; AP];
|
||
let mut against = [0i64; AP];
|
||
for i in 0..n {
|
||
parts[i] = magnitude * weights[i] / total_weight;
|
||
rems[i] = magnitude * weights[i] % total_weight;
|
||
against[i] = if loss { -given[i] } else { given[i] };
|
||
}
|
||
kernel::apportion_cumulative(magnitude, &mut parts[..n], &rems[..n], 1, &against[..n])
|
||
.expect("exact shares are fewer units short than there are members");
|
||
let mut sum = 0i64;
|
||
for i in 0..n {
|
||
if loss {
|
||
parts[i] = -parts[i];
|
||
}
|
||
sum += parts[i];
|
||
let drift = (given[i] + parts[i]) * total_weight - total * weights[i];
|
||
assert!(-total_weight < drift && drift < total_weight);
|
||
}
|
||
assert_eq!(sum, net);
|
||
kani::cover!(
|
||
loss && given[0] * total_weight != closed * weights[0],
|
||
"a loss, after an earlier split off the exact share"
|
||
);
|
||
}
|
||
|
||
/// [`drift_is_under_a_unit`] for two members at half each: every odd cent
|
||
/// a tie.
|
||
#[kani::proof]
|
||
#[kani::unwind(4)]
|
||
fn cumulative_close_never_drifts_a_unit_at_halves() {
|
||
drift_is_under_a_unit(&[1, 1]);
|
||
}
|
||
|
||
/// [`drift_is_under_a_unit`] for three members at 20% / 30% / 50%:
|
||
/// remainders that differ.
|
||
#[kani::proof]
|
||
#[kani::unwind(4)]
|
||
fn cumulative_close_never_drifts_a_unit_at_tenths() {
|
||
drift_is_under_a_unit(&[2, 3, 5]);
|
||
}
|
||
|
||
/// [`drift_is_under_a_unit`] for three members at 33.33% / 33.33% /
|
||
/// 33.34%, the nearest decimal shares to a third each.
|
||
#[kani::proof]
|
||
#[kani::unwind(4)]
|
||
fn cumulative_close_never_drifts_a_unit_at_near_thirds() {
|
||
drift_is_under_a_unit(&[3333, 3333, 3334]);
|
||
}
|
||
|
||
/// A cumulative members close balances: the nominal accounts' closing
|
||
/// lines and each member's part of the running split (any earlier split
|
||
/// of what was closed before) net to zero, whatever the true-up.
|
||
#[kani::proof]
|
||
#[kani::unwind(6)]
|
||
fn cumulative_closing_entry_balances() {
|
||
const NOMINAL: usize = 3;
|
||
let nominal: [i64; NOMINAL] = core::array::from_fn(|_| {
|
||
let a = any_amount();
|
||
kani::assume((-(1 << 38)..=1 << 38).contains(&a));
|
||
a
|
||
});
|
||
let mut entry = [(Side::Debit, 0i64); NOMINAL + 2];
|
||
let mut len = 0;
|
||
for &m in &nominal {
|
||
if let Some(line) = kernel::closing_line(m) {
|
||
entry[len] = line;
|
||
len += 1;
|
||
}
|
||
}
|
||
let net = kernel::net(entry[..len].iter().copied()).expect("no overflow");
|
||
// Two members at half each; earlier closes gave them any split of
|
||
// what they closed.
|
||
let closed: i64 = kani::any();
|
||
kani::assume((-(1 << 38)..=1 << 38).contains(&closed));
|
||
let first = any_amount();
|
||
let given = [first, closed - first];
|
||
let total = closed + net;
|
||
let (magnitude, loss) = (total.abs(), total < 0);
|
||
let mut parts = [magnitude / 2, magnitude / 2];
|
||
let rems = [magnitude % 2, magnitude % 2];
|
||
let against = if loss { [-given[0], -given[1]] } else { given };
|
||
if kernel::apportion_cumulative(magnitude, &mut parts, &rems, 1, &against).is_none() {
|
||
// Only an earlier split out of line range is refused here.
|
||
assert!(!given[1].in_line_range());
|
||
return;
|
||
}
|
||
let mut true_up = false;
|
||
for p in parts {
|
||
let p = if loss { -p } else { p };
|
||
if let Some(line) = kernel::credit_line(p) {
|
||
entry[len] = line;
|
||
len += 1;
|
||
true_up |= net != 0 && (p > 0) != (net > 0);
|
||
}
|
||
}
|
||
assert_eq!(wide_net(&entry[..len]), 0);
|
||
kani::cover!(true_up, "a true-up line on a member");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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]);
|
||
}
|
||
}
|