test(fuzz): the operation sequence gains documents, settlements and drafts (P12)
check / check (push) Waiting to run
portable-macos / portable (macos-latest) (push) Waiting to run
portable-windows / portable (windows-latest) (push) Waiting to run
two-user / two-user (push) Waiting to run

The sequence only knew accounts, lines, periods and closes, but that is no
longer where money moves: invoices, bills, their payments, write-offs and
voids are now first class, and the draft-state commands decide whether an
entry exists at all. Op gains each of them, on the same Ledger the store
wraps, with random parties, dates and amounts (including the raw-decimal
extremes).

The invariant after every sequence gains B-38: each control account equals
its documents as of three dates, and a control check too large to compute is
a refusal, never a difference. That is the identity that would catch a
payment tagged to the wrong document, or one that went unmatched, which the
trial balance alone cannot see.
This commit is contained in:
swaits committed 2026-11-05 08:36:15 -07:00
1 parent 7002e89efd
commit 48062b89f0
1 file changed
+327 -23
+327 -23
View File
@@ -2,27 +2,112 @@
// balanced pairs, foreign-currency lines, periods, and closes, with // balanced pairs, foreign-currency lines, periods, and closes, with
// amounts built from raw decimal bits (huge, tiny, odd scales). Refusals // amounts built from raw decimal bits (huge, tiny, odd scales). Refusals
// are fine; panics, hangs, and broken books are not. After any sequence: // are fine; panics, hangs, and broken books are not. After any sequence:
// the trial balance balances, every posted entry validates, and no draft // the trial balance balances, every posted entry validates, no draft sits
// sits in a closed period (the Lean invariant Inv). // in a closed period (the Lean invariant Inv), and the control accounts
// equal their documents (control_check's difference is zero, B-38).
//
// The documents (invoices, bills), their settlements and payments, write-offs
// and voids, and the draft-state ops (P12) run on the Ledger the store wraps:
// the store adds the journal, the engine is the state machine fuzzed here.
#![no_main] #![no_main]
use arbitrary::Arbitrary; use arbitrary::Arbitrary;
use chrono::NaiveDate; use chrono::NaiveDate;
use ledger_core::{AccountType, Currency, EntryStatus, Ledger, Money, Side}; use ledger_core::arap::{DocRef, DocumentLine};
use ledger_core::{
AccountType, Application, Currency, DraftState, EntryStatus, Ledger, Money, PaymentDetails,
SettleParams, Side,
};
use libfuzzer_sys::fuzz_target; use libfuzzer_sys::fuzz_target;
use rust_decimal::Decimal; use rust_decimal::Decimal;
#[derive(Arbitrary, Debug, Clone, Copy)] #[derive(Arbitrary, Debug, Clone, Copy)]
enum Op { enum Op {
AddAccount { code: u8, typ: u8, placeholder: bool }, AddAccount {
CreateEntry { day: u8 }, code: u8,
AddLine { entry: u8, account: u8, debit: bool, amount: Amount }, typ: u8,
Pair { entry: u8, debit: u8, credit: u8, amount: Amount }, placeholder: bool,
AddLineFx { entry: u8, account: u8, debit: bool, foreign: Amount, rate: Amount }, },
PostEntry { entry: u8 }, CreateEntry {
ReverseEntry { entry: u8, day: u8 }, day: u8,
CreatePeriod { start: u8, len: u8 }, },
ClosePeriod { period: u8 }, AddLine {
entry: u8,
account: u8,
debit: bool,
amount: Amount,
},
Pair {
entry: u8,
debit: u8,
credit: u8,
amount: Amount,
},
AddLineFx {
entry: u8,
account: u8,
debit: bool,
foreign: Amount,
rate: Amount,
},
PostEntry {
entry: u8,
},
ReverseEntry {
entry: u8,
day: u8,
},
CreatePeriod {
start: u8,
len: u8,
},
ClosePeriod {
period: u8,
},
CreateInvoice {
day: u8,
terms: u8,
amount: Amount,
},
CreateBill {
day: u8,
terms: u8,
amount: Amount,
},
/// A payment on a document (mod the documents) from the bank or a
/// fuzzed account, with a reference or not.
Pay {
doc: u8,
day: u8,
amount: Amount,
from: u8,
reference: bool,
},
/// One payment settling two documents, the first with a discount.
Settle {
a: u8,
b: u8,
day: u8,
cash: Amount,
discount: Amount,
},
WriteOff {
doc: u8,
day: u8,
amount: Amount,
},
VoidDocument {
doc: u8,
day: u8,
},
SetDraftState {
entry: u8,
awaiting: bool,
note: u8,
},
DiscardDraft {
entry: u8,
},
} }
/// A decimal from raw parts: any mantissa up to 96 bits, any scale 0-28. /// A decimal from raw parts: any mantissa up to 96 bits, any scale 0-28.
@@ -42,7 +127,13 @@ impl Amount {
// Mostly ordinary money, so entries balance and post. // Mostly ordinary money, so entries balance and post.
return Decimal::new(i64::from(self.lo % 1_000_000), 2); return Decimal::new(i64::from(self.lo % 1_000_000), 2);
} }
Decimal::from_parts(self.lo, self.mid, self.hi, self.negative, u32::from(self.scale % 29)) Decimal::from_parts(
self.lo,
self.mid,
self.hi,
self.negative,
u32::from(self.scale % 29),
)
} }
} }
@@ -63,34 +154,85 @@ fn day(d: u8) -> NaiveDate {
fuzz_target!(|ops: Vec<Op>| { fuzz_target!(|ops: Vec<Op>| {
let mut l = Ledger::new(Currency::USD); let mut l = Ledger::new(Currency::USD);
let mut entries: Vec<u64> = Vec::new(); let mut entries: Vec<u64> = Vec::new();
let mut docs: Vec<DocRef> = Vec::new();
// The books' fixed parts: the control accounts documents post to, a bank,
// a customer and a vendor.
let _ = (
l.add_control_account(&ledger_core::RECEIVABLES),
l.add_control_account(&ledger_core::PAYABLES),
l.add_control_account(&ledger_core::BAD_DEBTS),
);
let bank = l
.add_account("1900", "Bank", AccountType::Asset, None, false)
.ok();
let customer = l.add_customer("Fernhill Labs").ok();
let vendor = l.add_vendor("Tobin Harlow Supply").ok();
let doc_line = |a: Amount| {
vec![DocumentLine {
description: "work".into(),
amount: a.get(),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}]
};
let pick = |v: &[u64], i: u8| v.get(i as usize % v.len().max(1)).copied(); let pick = |v: &[u64], i: u8| v.get(i as usize % v.len().max(1)).copied();
for op in ops.into_iter().take(200) { for op in ops.into_iter().take(200) {
let accounts: Vec<u64> = l.chart().all().map(|a| a.id).collect(); let accounts: Vec<u64> = l.chart().all().map(|a| a.id).collect();
match op { match op {
Op::AddAccount { code, typ, placeholder } => { Op::AddAccount {
let _ = l.add_account(&format!("{}", 1000 + u32::from(code % 40)), "f", account_type(typ), None, placeholder); code,
typ,
placeholder,
} => {
let _ = l.add_account(
&format!("{}", 1000 + u32::from(code % 40)),
"f",
account_type(typ),
None,
placeholder,
);
} }
Op::CreateEntry { day: d } => { Op::CreateEntry { day: d } => {
if let Ok(id) = l.try_create_entry(day(d), "f") { if let Ok(id) = l.try_create_entry(day(d), "f") {
entries.push(id); entries.push(id);
} }
} }
Op::AddLine { entry, account, debit, amount } => { Op::AddLine {
entry,
account,
debit,
amount,
} => {
if let (Some(e), Some(a)) = (pick(&entries, entry), pick(&accounts, account)) { if let (Some(e), Some(a)) = (pick(&entries, entry), pick(&accounts, account)) {
let side = if debit { Side::Debit } else { Side::Credit }; let side = if debit { Side::Debit } else { Side::Credit };
let _ = l.add_line(e, a, side, Money::new(amount.get(), Currency::USD)); let _ = l.add_line(e, a, side, Money::new(amount.get(), Currency::USD));
} }
} }
Op::Pair { entry, debit, credit, amount } => { Op::Pair {
if let (Some(e), Some(a), Some(b)) = entry,
(pick(&entries, entry), pick(&accounts, debit), pick(&accounts, credit)) debit,
{ credit,
amount,
} => {
if let (Some(e), Some(a), Some(b)) = (
pick(&entries, entry),
pick(&accounts, debit),
pick(&accounts, credit),
) {
let m = Money::new(amount.get(), Currency::USD); let m = Money::new(amount.get(), Currency::USD);
let _ = l.add_line(e, a, Side::Debit, m); let _ = l.add_line(e, a, Side::Debit, m);
let _ = l.add_line(e, b, Side::Credit, m); let _ = l.add_line(e, b, Side::Credit, m);
} }
} }
Op::AddLineFx { entry, account, debit, foreign, rate } => { Op::AddLineFx {
entry,
account,
debit,
foreign,
rate,
} => {
if let (Some(e), Some(a)) = (pick(&entries, entry), pick(&accounts, account)) { if let (Some(e), Some(a)) = (pick(&entries, entry), pick(&accounts, account)) {
let side = if debit { Side::Debit } else { Side::Credit }; let side = if debit { Side::Debit } else { Side::Credit };
let _ = l.add_line_fx(e, a, side, Currency::EUR, foreign.get(), rate.get()); let _ = l.add_line_fx(e, a, side, Currency::EUR, foreign.get(), rate.get());
@@ -111,6 +253,147 @@ fuzz_target!(|ops: Vec<Op>| {
Op::CreatePeriod { start, len } => { Op::CreatePeriod { start, len } => {
let _ = l.create_period("p", day(start), day(start.saturating_add(len % 60))); let _ = l.create_period("p", day(start), day(start.saturating_add(len % 60)));
} }
Op::CreateInvoice {
day: d,
terms,
amount,
} => {
if let Some(c) = customer
&& let Ok(id) = l.create_invoice_with(
c,
day(d),
day(d) + chrono::Days::new(u64::from(terms)),
doc_line(amount),
Default::default(),
)
{
docs.push(DocRef::Invoice(id));
}
}
Op::CreateBill {
day: d,
terms,
amount,
} => {
if let Some(v) = vendor
&& let Ok(id) = l.create_bill_with(
v,
day(d),
day(d) + chrono::Days::new(u64::from(terms)),
doc_line(amount),
Default::default(),
)
{
docs.push(DocRef::Bill(id));
}
}
Op::Pay {
doc,
day: d,
amount,
from,
reference,
} => {
if !docs.is_empty() {
let cash = if from % 4 == 0 {
pick(&accounts, from)
} else {
bank
};
let details = PaymentDetails {
instrument: None,
reference: reference.then(|| format!("trace {doc}{d}")),
};
if let Some(cash) = cash {
let _ = match docs[doc as usize % docs.len()] {
DocRef::Invoice(id) => {
l.receive_payment_with(id, day(d), amount.get(), cash, &details)
}
DocRef::Bill(id) => {
l.pay_bill_with(id, day(d), amount.get(), cash, &details)
}
};
}
}
}
Op::Settle {
a,
b,
day: d,
cash,
discount,
} => {
if let (false, Some(cash_account)) = (docs.is_empty(), bank) {
let first = docs[a as usize % docs.len()];
let second = docs[b as usize % docs.len()];
let party = match first {
DocRef::Invoice(id) => l.get_invoice(id).map(|i| i.customer_id),
DocRef::Bill(id) => l.get_bill(id).map(|v| v.vendor_id),
};
if let Some(party) = party {
let _ = l.settle(SettleParams {
date: day(d),
description: "two documents, one payment".into(),
cash_account,
party,
applications: vec![
Application {
doc: first,
cash: cash.get(),
discount: discount.get(),
discount_reason: Some("fuzz".into()),
},
Application {
doc: second,
cash: cash.get(),
discount: Decimal::ZERO,
discount_reason: None,
},
],
fee: None,
instrument: None,
reference: None,
});
}
}
}
Op::WriteOff {
doc,
day: d,
amount,
} => {
if !docs.is_empty()
&& let DocRef::Invoice(id) = docs[doc as usize % docs.len()]
{
let _ = l.write_off(id, day(d), amount.get(), "uncollectible");
}
}
Op::VoidDocument { doc, day: d } => {
if !docs.is_empty() {
let _ = l.void_document(docs[doc as usize % docs.len()], day(d), "duplicate");
}
}
Op::SetDraftState {
entry,
awaiting,
note,
} => {
if let Some(e) = pick(&entries, entry) {
let state = if awaiting {
DraftState::AwaitingEvidence {
note: format!("receipt {note}"),
}
} else {
DraftState::Building
};
let _ = l.set_draft_state(e, state);
}
}
Op::DiscardDraft { entry } => {
if let Some(e) = pick(&entries, entry) {
let _ = l.discard_draft(e, "fuzz");
}
}
Op::ClosePeriod { period } => { Op::ClosePeriod { period } => {
let ids: Vec<u64> = l.list_periods().iter().map(|p| p.id).collect(); let ids: Vec<u64> = l.list_periods().iter().map(|p| p.id).collect();
if let Some(p) = pick(&ids, period) { if let Some(p) = pick(&ids, period) {
@@ -122,13 +405,34 @@ fuzz_target!(|ops: Vec<Op>| {
let tb = ledger_core::reports::trial_balance(&l); let tb = ledger_core::reports::trial_balance(&l);
assert_eq!(tb.total_debits, tb.total_credits, "trial balance broken"); assert_eq!(tb.total_debits, tb.total_credits, "trial balance broken");
for e in l.posted_entries() { for e in l.posted_entries() {
assert!(e.validate(l.chart()).is_ok(), "posted entry {} invalid", e.id()); assert!(
e.validate(l.chart()).is_ok(),
"posted entry {} invalid",
e.id()
);
}
// The control accounts equal their documents, at any day (B-38): too
// large to compute is a refusal, never a difference.
for d in [0u8, 60, 199] {
if let Ok(checks) = l.control_check(day(d)) {
for c in checks {
assert_eq!(
c.difference,
Decimal::ZERO,
"{} differs from its documents",
c.role
);
}
}
} }
for id in 1..l.next_entry_id() { for id in 1..l.next_entry_id() {
if let Some(e) = l.get_entry(id) if let Some(e) = l.get_entry(id)
&& e.status() == EntryStatus::Draft && e.status() == EntryStatus::Draft
{ {
assert!(l.closed_period_containing(e.date()).is_none(), "draft in a closed period"); assert!(
l.closed_period_containing(e.date()).is_none(),
"draft in a closed period"
);
} }
} }
}); });