Files
wanderbooks/crates/ledger-core/tests/oracle_gnucash.rs
T
swaitsandClaude Opus 5.5 beb1fcf233 test(core): spec-blind suites and a live GnuCash oracle; four fixes they found
Max-testing pass, steps 2-4.

Spec-blind tests (rank 1-2): four agents, each given only the specs and
the public interface (doc comments and signatures, every body removed,
never src/ or the existing tests), wrote tests/spec_reports.rs (62),
spec_ledger.rs (72), spec_arap.rs (42), spec_assets_payroll.rs (50):
hand-computed examples per scenario and a proptest law per invariant
(conservation, partition, invariance, scaling, reversal cancels,
"a refusal leaves the books unchanged" over random invalid commands).
They found:

1. A refused payroll run changed the books: an all-zero run passed
   validation, then failed at post after creating seven payroll
   accounts and using up an entry id. validate_payroll_lines now
   refuses a run with no gross pay.
2. A refused add_account used up an account id (empty code, duplicate,
   unknown parent). The id is now taken only when the account is added.
3. Stale prose: close_period_to and members_equity still described the
   old last-member rounding.
4. Open (left #[ignore]d pending Stephen): depreciation_schedule's doc
   says "through the month containing" the date; the code prorates that
   month by days.

GnuCash oracle (rank 3, an oracle never the spec): `just oracle` builds
GnuCash 5.10 in a container pinned by base digest and package versions
(oracle/gnucash/) and runs tests/oracle_gnucash.rs: 200 seeded random
books built through wanderbooks (entries, foreign lines, reversals,
monthly closes), every account's balance at month ends and random dates
and each month's P&L, compared exactly: 15,800 comparisons. First run:
4,724 differences, all from foreign lines; without them, zero.
Adjudicated from principles (rules.md B-3): wanderbooks booked
foreign * rate unrounded ($108.34567); a ledger in one money holds no
fractions of its smallest unit, and its cash must match a bank. Stephen
decided: book in the base currency's minor unit, half away from zero.
add_line_fx now rounds; minor units per ISO 4217 list one (2026-09-17,
SIX), generated into Currency::minor_units and recorded in
docs/spec/sources/iso-4217.md. Replay now takes the journaled base
amount (replay_add_line_fx), so lines booked before replay exactly as
booked (contract: books_written_under_older_rules_still_open). Now:
zero differences, foreign lines included.

Tests: 449, from 223.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-13 14:39:06 -06:00

290 lines
11 KiB
Rust

//! Cross-check against GnuCash (rank 3: a differential oracle).
//!
//! GnuCash is *an* independent implementation of double entry, not the
//! spec (docs/oracle.md, "Who is right"). This test builds random books
//! through wanderbooks' engine (entries, foreign-currency lines, reversals,
//! period closes), hands their posted entries to GnuCash 5.10 running in a
//! pinned container (oracle/gnucash/), and compares every account's
//! balance at month ends and random dates, exactly. A difference is a
//! finding to adjudicate from principles (rules.md), never a reason to
//! change wanderbooks to match.
//!
//! Needs Docker: `just oracle` builds the image and runs this.
use chrono::NaiveDate;
use ledger_core::reports::{profit_and_loss_for_period, trial_balance_as_of};
use ledger_core::{AccountId, AccountType, Currency, Ledger, Money, Side};
use rust_decimal::Decimal;
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::io::Write;
use std::process::{Command, Stdio};
/// Books per run, and the seed of the first.
const BOOKS: u64 = 200;
const SEED: u64 = 0x005e_ed0f_b00c;
/// A small deterministic generator (xorshift64*): the run is reproducible
/// from its seed, with no new dependency.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 >> 12;
self.0 ^= self.0 << 25;
self.0 ^= self.0 >> 27;
self.0.wrapping_mul(0x2545_f491_4f6c_dd1d)
}
fn below(&mut self, n: u64) -> u64 {
self.next() % n
}
}
fn d(m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
}
fn month_end(m: u32) -> NaiveDate {
if m == 12 {
d(12, 31)
} else {
d(m + 1, 1).pred_opt().unwrap()
}
}
/// Random books through the engine's public commands. Closing months
/// (when `closes`) happens in order, after the month's entries.
fn generate(seed: u64, closes: bool, fx: bool) -> (Ledger, Vec<AccountId>) {
let mut r = Rng(seed | 1);
let mut l = Ledger::new(Currency::USD);
let chart = [
("1000", "Cash", AccountType::Asset),
("1500", "Equipment", AccountType::Asset),
("2000", "Payables", AccountType::Liability),
("3000", "Capital", AccountType::Equity),
("4000", "Sales", AccountType::Revenue),
("4100", "Interest", AccountType::Revenue),
("6000", "Rent", AccountType::Expense),
("6100", "Software", AccountType::Expense),
];
let ids: Vec<AccountId> = chart
.iter()
.map(|(c, n, t)| l.add_account(c, n, *t, None, false).unwrap())
.collect();
let mut posted = Vec::new();
for month in 1..=6u32 {
let entries = 1 + r.below(6);
for _ in 0..entries {
let day = 1 + r.below(28) as u32;
let e = l.create_entry(d(month, day), "generated");
let lines = 1 + r.below(3);
let mut net = Decimal::ZERO;
for _ in 0..lines {
let account = ids[r.below(ids.len() as u64) as usize];
let side = if r.below(2) == 0 {
Side::Debit
} else {
Side::Credit
};
if fx && r.below(8) == 0 {
// A foreign line: odd decimal places in the base amount.
let foreign = Decimal::new(1 + r.below(500_000) as i64, 2);
let rate = Decimal::new(5_000 + r.below(20_000) as i64, 4);
l.add_line_fx(e, account, side, Currency::EUR, foreign, rate)
.unwrap();
// Balance against what was booked (rounded to the cent).
let base = l
.get_entry(e)
.unwrap()
.lines()
.last()
.unwrap()
.amount
.amount;
net += if side == Side::Debit { base } else { -base };
} else {
let amount = Decimal::new(1 + r.below(1_000_000) as i64, 2);
l.add_line(e, account, side, Money::new(amount, Currency::USD))
.unwrap();
net += if side == Side::Debit { amount } else { -amount };
}
}
// A plug line on cash balances the entry.
if !net.is_zero() {
let side = if net > Decimal::ZERO {
Side::Credit
} else {
Side::Debit
};
l.add_line(e, ids[0], side, Money::new(net.abs(), Currency::USD))
.unwrap();
} else {
let amount = Money::new(Decimal::ONE, Currency::USD);
l.add_line(e, ids[0], Side::Debit, amount).unwrap();
l.add_line(e, ids[3], Side::Credit, amount).unwrap();
}
if l.post_entry(e).is_ok() {
posted.push(e);
}
}
// Now and then, reverse an earlier entry of this month.
if r.below(3) == 0
&& let Some(&e) = posted.last()
{
let date = l.get_entry(e).unwrap().date();
let _ = l.reverse_entry(e, date, "reversal");
}
if closes {
let p = l
.create_period(&format!("2026-{month:02}"), d(month, 1), month_end(month))
.unwrap();
l.close_period(p).unwrap();
}
}
(l, ids)
}
fn book_json(l: &Ledger, as_of: &[NaiveDate]) -> Value {
let accounts: Vec<Value> = l
.chart()
.all()
.map(|a| {
json!({"id": a.id, "code": a.code, "name": a.name,
"type": format!("{:?}", a.account_type)})
})
.collect();
let entries: Vec<Value> = l
.posted_entries()
.map(|e| {
json!({
"date": e.date().to_string(),
"description": e.description(),
"lines": e.lines().iter().map(|ln| json!({
"account": ln.account,
"side": if ln.side == Side::Debit { "debit" } else { "credit" },
"amount": ln.amount.amount.to_string(),
})).collect::<Vec<_>>(),
})
})
.collect();
json!({
"accounts": accounts,
"entries": entries,
"as_of": as_of.iter().map(|d| d.to_string()).collect::<Vec<_>>(),
})
}
/// wanderbooks' trial balance as of a date, as GnuCash reports balances:
/// debits positive, by account id, zero for accounts not listed.
fn ours(l: &Ledger, as_of: NaiveDate) -> BTreeMap<AccountId, Decimal> {
let tb = trial_balance_as_of(l, as_of);
let mut out: BTreeMap<AccountId, Decimal> =
l.chart().all().map(|a| (a.id, Decimal::ZERO)).collect();
for line in tb.lines {
let id = l.chart().find_by_code(&line.code).unwrap().id;
out.insert(id, line.debit - line.credit);
}
out
}
fn gnucash(books: &[Value]) -> Vec<Value> {
let mut child = Command::new("docker")
.args(["run", "--rm", "-i", "wanderbooks-gnucash-oracle"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("docker is needed: run `just oracle`");
child
.stdin
.take()
.unwrap()
.write_all(serde_json::to_string(books).unwrap().as_bytes())
.unwrap();
let out = child.wait_with_output().unwrap();
assert!(
out.status.success(),
"the GnuCash oracle failed; build it with `just oracle`"
);
serde_json::from_slice(&out.stdout).expect("oracle output is JSON")
}
fn dec(v: &Value) -> Decimal {
v.as_str().unwrap().parse().unwrap()
}
#[test]
#[ignore = "needs Docker and the GnuCash image: `just oracle`"]
fn balances_match_gnucash_on_generated_books() {
let mut cases = Vec::new();
for i in 0..BOOKS {
let seed = SEED.wrapping_add(i.wrapping_mul(0x9e37_79b9_7f4a_7c15));
let closes = i % 2 == 1;
let (l, _) = generate(seed, closes, true);
let mut r = Rng(seed ^ 0xa5a5);
let mut as_of: Vec<NaiveDate> = (1..=6).map(month_end).collect();
for _ in 0..3 {
as_of.push(d(1 + r.below(6) as u32, 1 + r.below(28) as u32));
}
cases.push((seed, closes, l, as_of));
}
let books: Vec<Value> = cases
.iter()
.map(|(_, _, l, as_of)| book_json(l, as_of))
.collect();
let results = gnucash(&books);
assert_eq!(results.len(), cases.len());
let mut diffs = Vec::new();
let mut compared = 0usize;
for ((seed, closes, l, as_of), result) in cases.iter().zip(&results) {
for date in as_of {
let theirs = &result["balances"][date.to_string()];
for (id, mine) in ours(l, *date) {
let g = dec(&theirs[id.to_string()]);
compared += 1;
if g != mine {
let code = &l.chart().get(id).unwrap().code;
diffs.push(format!(
"seed {seed:#x} (closes: {closes}) account {code} as of {date}: wanderbooks {mine}, GnuCash {g}"
));
}
}
}
// A month's P&L is the change in its income and expense balances,
// on books with no closing entries (GnuCash books a close as an
// ordinary transaction; wanderbooks' period P&L leaves it out).
if !closes {
for m in 2..=6u32 {
let pnl = profit_and_loss_for_period(l, d(m, 1), month_end(m), None).unwrap();
let before = &result["balances"][month_end(m - 1).to_string()];
let after = &result["balances"][month_end(m).to_string()];
let mut gnucash_net = Decimal::ZERO;
for a in l.chart().all() {
if matches!(a.account_type, AccountType::Revenue | AccountType::Expense) {
let k = a.id.to_string();
// Income is credit-normal: net income = -(change).
gnucash_net -= dec(&after[&k]) - dec(&before[&k]);
}
}
compared += 1;
if gnucash_net != pnl.net_income() {
diffs.push(format!(
"seed {seed:#x} P&L 2026-{m:02}: wanderbooks {}, GnuCash {gnucash_net}",
pnl.net_income()
));
}
}
}
}
eprintln!(
"compared {compared} balances and P&Ls across {} books",
cases.len()
);
assert!(
diffs.is_empty(),
"{} difference(s) with GnuCash; adjudicate each from rules.md:\n{}",
diffs.len(),
diffs.join("\n")
);
}