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
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-13 14:39:06 -06:00
1 parent 0d4a5f8f53
commit beb1fcf233
20 files changed
+8110 -51

No files matched your search

+3 -2
View File
@@ -348,8 +348,9 @@ pub enum LedgerError {
#[error("replay id mismatch: journal says {0}, allocator expects {1}")]
ReplayIdMismatch(u64, u64),
/// A payroll run must have at least one employee line.
#[error("payroll run must have at least one line")]
/// A payroll run must pay some wages: at least one employee line with
/// gross pay.
#[error("payroll run must have at least one employee with gross pay")]
EmptyPayroll,
/// Payroll amounts must not be negative.
+65 -31
View File
@@ -541,8 +541,8 @@ impl Ledger {
parent: Option<AccountId>,
placeholder: bool,
) -> Result<AccountId, LedgerError> {
// The id is used only if the account is: a refusal uses up nothing.
let id = self.next_account_id;
self.next_account_id += 1;
self.chart.add(Account {
id,
code: code.to_string(),
@@ -554,6 +554,7 @@ impl Ledger {
tax_category: None,
deductible_percent: None,
})?;
self.next_account_id += 1;
Ok(id)
}
@@ -714,10 +715,26 @@ impl Ledger {
}
/// Add a foreign-currency line to a draft entry. `foreign_amount` is the
/// amount in `foreign_currency`; `fx_rate` converts foreign -> base
/// (base = foreign * rate). The stored `amount` is the base-currency
/// equivalent; the foreign original is kept for reference. Rates are
/// exact Decimals, never floats.
/// amount in `foreign_currency`; `fx_rate` converts foreign -> base.
/// The stored `amount` is foreign * rate rounded to the base currency's
/// minor unit (cents for USD), half away from zero; the foreign
/// original is kept for reference. Rates are exact Decimals, never
/// floats.
///
/// ```text
/// GOAL: book a foreign-currency amount in the ledger's one money.
/// SOURCE: docs/spec/rules.md B-3 (Pacioli ch. 36: one money; ASC
/// 830-20-30-1: at the transaction-date rate); minor units: ISO
/// 4217; rounding: DECISION (Stephen, 2026-10-13).
/// INVARIANTS:
/// 1. The base amount MUST be a whole number of the base currency's
/// smallest unit, within half a unit of foreign * rate.
/// 2. NEVER convert into the base currency itself.
/// SCENARIOS:
/// 1. IF 100.00 EUR at 1.0834567 into USD: SHOULD book 108.35.
/// 2. IF 10.00 USD at 151.237 into JPY: SHOULD book 1512.
/// 3. IF the rounded amount is zero: SHOULD refuse.
/// ```
pub fn add_line_fx(
&mut self,
entry: EntryId,
@@ -728,27 +745,6 @@ impl Ledger {
fx_rate: Decimal,
) -> Result<(), LedgerError> {
self.check_line_account(account)?;
self.replay_add_line_fx(
entry,
account,
side,
foreign_currency,
foreign_amount,
fx_rate,
)
}
/// [`Self::add_line_fx`] without the account check added on
/// 2026-10-13, for replaying journals written before it.
pub fn replay_add_line_fx(
&mut self,
entry: EntryId,
account: AccountId,
side: Side,
foreign_currency: Currency,
foreign_amount: Decimal,
fx_rate: Decimal,
) -> Result<(), LedgerError> {
if foreign_amount <= Decimal::ZERO {
return Err(LedgerError::InvalidDocumentLineAmount(foreign_amount));
}
@@ -761,18 +757,55 @@ impl Ledger {
found: foreign_currency.code().to_string(),
});
}
let base_amount = foreign_amount
let exact = foreign_amount
.checked_mul(fx_rate)
.ok_or(LedgerError::AmountOutOfRange)?;
// Booked in the base currency's smallest unit (cents for USD), half
// away from zero: a ledger in one money holds no fractions of its
// smallest unit (rules.md B-3; DECISION, Stephen 2026-10-13, after
// the GnuCash cross-check found the difference).
let base_amount = match self.base_currency.minor_units() {
Some(dp) => exact
.round_dp_with_strategy(dp, rust_decimal::RoundingStrategy::MidpointAwayFromZero),
None => exact,
};
if base_amount <= Decimal::ZERO {
return Err(LedgerError::InvalidFxRate(fx_rate));
}
let base = self.base_currency;
self.replay_add_line_fx(
entry,
account,
side,
Money::new(base_amount, self.base_currency),
foreign_currency,
foreign_amount,
)
}
/// A foreign-currency line exactly as a journal recorded it: the base
/// amount as booked (rounded or not, by the rule of its day) and the
/// foreign original. For replay only; new work uses
/// [`Self::add_line_fx`].
pub fn replay_add_line_fx(
&mut self,
entry: EntryId,
account: AccountId,
side: Side,
amount: Money,
foreign_currency: Currency,
foreign_amount: Decimal,
) -> Result<(), LedgerError> {
if amount.currency != self.base_currency {
return Err(LedgerError::CurrencyMismatch {
expected: self.base_currency.code().to_string(),
found: amount.currency.code().to_string(),
});
}
let e = self.draft_mut(entry)?;
e.push_line(JournalLine {
account,
side,
amount: Money::new(base_amount, base),
amount,
foreign_currency: Some(foreign_currency),
foreign_amount: Some(foreign_amount),
class_id: None,
@@ -1168,8 +1201,9 @@ impl Ledger {
/// - `RetainedEarnings`: account `3999`, created if missing.
/// - `Account`: one equity account (a sole proprietor's capital).
/// - `Members`: each member's capital account gets their share of
/// the net, to the cent (the last absorbs rounding), and their own
/// distribution account.
/// the net, to the cent (odd cents by largest remainder; closes
/// journaled before 2026-10-13 replay with the last member
/// absorbing them), and their own distribution account.
/// 3. Marks the period closed.
///
/// Returns the closing entry's id, or `None` if there was nothing to
+18
View File
@@ -39,6 +39,24 @@ impl Currency {
Ok(Currency(upper))
}
/// The currency's minor units: how many decimal places its smallest
/// unit has (2 for USD's cents, 0 for JPY, 3 for KWD), or `None` for
/// codes ISO 4217 gives none (gold, SDRs). Unlisted codes are 2.
///
/// SOURCE: ISO 4217 list one, published 2026-09-17
/// (docs/spec/sources/iso-4217.md); generated from it, not typed in.
pub fn minor_units(&self) -> Option<u32> {
match self.code() {
"BIF" | "CLP" | "DJF" | "GNF" | "ISK" | "JPY" | "KMF" | "KRW" | "PYG" | "RWF"
| "UGX" | "UYI" | "VND" | "VUV" | "XAF" | "XOF" | "XPF" => Some(0),
"BHD" | "IQD" | "JOD" | "KWD" | "LYD" | "OMR" | "TND" => Some(3),
"CLF" | "UYW" => Some(4),
"XAG" | "XAU" | "XBA" | "XBB" | "XBC" | "XBD" | "XDR" | "XPD" | "XPT" | "XSU"
| "XTS" | "XUA" | "XXX" => None,
_ => Some(2),
}
}
/// The three-letter code, e.g. `"USD"`.
pub fn code(&self) -> &str {
std::str::from_utf8(&self.0).expect("currency codes are ASCII by construction")
+4 -1
View File
@@ -91,7 +91,10 @@ pub struct PayrollAccounts {
/// amounts, no negative net pay. Journaling stores run this before writing
/// anything, so a doomed run never leaves journal lines behind.
pub fn validate_payroll_lines(lines: &[PayrollLine]) -> Result<(), LedgerError> {
if lines.is_empty() {
// No wages at all is no payroll: refused before anything changes
// (found by the spec-blind tests, 2026-10-13: an all-zero run passed
// validation, then failed at post after creating accounts).
if lines.is_empty() || lines.iter().all(|l| l.gross_pay.is_zero()) {
return Err(LedgerError::EmptyPayroll);
}
for line in lines {
+2 -2
View File
@@ -810,8 +810,8 @@ pub struct MembersEquityStatement {
/// distributions (debits). Earnings are partnership earnings: net income
/// for the period, and earnings from before it not yet distributed (retained
/// earnings plus unclosed income), are allocated by share. Allocation is to
/// the cent with the last member absorbing rounding, so every row's total
/// is exact and the ending total equals balance-sheet equity.
/// the cent, odd cents by largest remainder, so every row's total is exact
/// and the ending total equals balance-sheet equity.
///
/// ```text
/// GOAL: each member's capital roll-forward for a partnership over a
+46
View File
@@ -1016,3 +1016,49 @@ fn a_line_on_an_unknown_or_placeholder_account_is_refused_when_added() {
"refused lines aren't added"
);
}
/// Spec (rules.md B-3; DECISION, Stephen 2026-10-13, after the GnuCash
/// cross-check): a foreign line is booked in the base currency's smallest
/// unit, half away from zero; the foreign original is kept.
#[test]
fn a_foreign_line_is_booked_in_whole_base_units() {
let book = |base: Currency, foreign: Currency, amount: &str, rate: &str| {
let mut l = Ledger::new(base);
let a = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let e = l.create_entry(date(5), "fx");
let r = l.add_line_fx(
e,
a,
Side::Debit,
foreign,
amount.parse().unwrap(),
rate.parse().unwrap(),
);
r.map(|()| {
let line = l.get_entry(e).unwrap().lines()[0].clone();
(line.amount.amount, line.foreign_amount)
})
};
let dec = |s: &str| s.parse::<Decimal>().unwrap();
// 100.00 EUR x 1.0834567 = 108.34567 USD -> 108.35.
assert_eq!(
book(Currency::USD, Currency::EUR, "100.00", "1.0834567").unwrap(),
(dec("108.35"), Some(dec("100.00")))
);
// 10.00 USD x 151.237 = 1512.37 JPY -> 1512 (yen have no minor unit).
assert_eq!(
book(Currency::JPY, Currency::USD, "10.00", "151.237")
.unwrap()
.0,
dec("1512")
);
// Exactly half a cent rounds away from zero.
assert_eq!(
book(Currency::USD, Currency::EUR, "1", "0.125").unwrap().0,
dec("0.13")
);
// Rounds to nothing: refused.
assert!(book(Currency::USD, Currency::EUR, "0.001", "1").is_err());
}
+289
View File
@@ -0,0 +1,289 @@
//! 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")
);
}
@@ -1,8 +0,0 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 973b72cb286deab3cabcf8a48a797a1aa76e660f1a6f3a7046d02f655ad467a6 # shrinks to cents = 8074248648, shares = [0.0877914951989026063100137174, 0.327846364883401920438957476, 0.2235939643347050754458161866, 0.3607681755829903978052126200]
cc 181075cd6abb81d059781749aadb13e990e59e6658279089e173fe655bd6bcf2 # shrinks to cents = 4209937262, shares = [0.3215789473684210526315789474, 0.2426315789473684210526315789, 0.1663157894736842105263157895, 0.2694736842105263157894736842]
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -2929,18 +2929,18 @@ pub(crate) fn replay_line_into_ledger(
let foreign_amount: Decimal = get_str(p, "foreign_amount")?
.parse()
.map_err(|_| StoreError::JournalCorrupt("bad foreign amount".into()))?;
let fx_rate: Decimal = get_str(p, "fx_rate")?
.parse()
.map_err(|_| StoreError::JournalCorrupt("bad fx rate".into()))?;
// The journaled base amount is the record: lines booked
// before 2026-10-13 were not rounded to the cent, and
// replay keeps them exactly as booked.
ledger.replay_add_line_fx(
id,
account,
side,
Money::new(amount, currency),
Currency::new(fc).map_err(|_| {
StoreError::JournalCorrupt("bad foreign currency".into())
})?,
foreign_amount,
fx_rate,
)?;
}
None => {
+14 -1
View File
@@ -1043,6 +1043,13 @@ fn books_written_under_older_rules_still_open() {
json!({"id": 1, "account": 999, "side": "debit", "amount": "10.00", "currency": "USD"}),
)
.unwrap();
// A foreign line booked before rounding to the cent (2026-10-13).
w.append(
"entry.line_added",
json!({"id": 1, "account": cash, "side": "credit", "amount": "108.34567", "currency": "USD",
"foreign_currency": "EUR", "foreign_amount": "100.00", "fx_rate": "1.0834567"}),
)
.unwrap();
drop(w);
// Opening rebuilds from the journal; it must accept all of it.
let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE));
@@ -1050,5 +1057,11 @@ fn books_written_under_older_rules_still_open() {
store.verify().unwrap();
assert_eq!(store.ledger().chart().len(), 2);
assert_eq!(store.ledger().list_periods().len(), 2);
assert_eq!(store.ledger().get_entry(1).unwrap().lines().len(), 1);
let lines = store.ledger().get_entry(1).unwrap().lines();
assert_eq!(lines.len(), 2);
// Replayed exactly as booked, not re-rounded.
assert_eq!(
lines[1].amount.amount,
"108.34567".parse::<Decimal>().unwrap()
);
}
+9 -1
View File
@@ -79,7 +79,15 @@ periods, closing to net income), and a property test bridges the two.
`just prove`; see `docs/verification.md`. Its first pass found three
engine bugs that Layers 1–4 had all missed.
**Layer 5 — Cross-implementation diff harness (roadmap phase 3).** A
**Layer 5 — Cross-implementation diff harness (live: GnuCash).**
`just oracle` builds GnuCash 5.10 in a pinned container
(`oracle/gnucash/`) and runs `tests/oracle_gnucash.rs`: 200 random books
built through wanderbooks (entries, foreign lines, reversals, closes),
every account's balance at month ends and random dates, and each month's
P&L, compared exactly (15,800 comparisons). Its first run found one real
difference, adjudicated from principles: wanderbooks kept sub-cent
amounts on foreign lines; it now books in cents (rules.md B-3). The
original plan follows. A
`ledger-oracle` crate will run fixture books through our engine and through
other implementations (GnuCash headless first), then diff `snapshot()`
output automatically: `cargo xtask oracle`. This is the direct analog of
+10 -1
View File
@@ -59,8 +59,17 @@ that every line reads one way (2026-10-13, found by the formal pass).
functional currency of the recording entity by use of the exchange rate
in effect at that date."
A foreign line is booked in the base currency's smallest unit (ISO 4217
minor units, `sources/iso-4217.md`), half away from zero; a ledger in one
money holds no fractions of its smallest unit, and its cash can then
match a bank statement: DECISION (Stephen, 2026-10-13), after the GnuCash
cross-check found wanderbooks kept $108.34567 where GnuCash booked
$108.35. Lines booked before then replay as booked.
**Enforced:** `Ledger::add_line` (base currency only),
`Ledger::add_line_fx` (base amount = foreign amount × rate, original kept).
`Ledger::add_line_fx` (base amount = foreign amount × rate, rounded to
the minor unit; original kept). **Checked:**
`a_foreign_line_is_booked_in_whole_base_units`; the GnuCash oracle.
**Checked:** `foreign_currency_line_is_rejected`, `fx_overflow_is_an_error`.
## B-4 Five elements, closed list
+24
View File
@@ -0,0 +1,24 @@
# ISO 4217: currency minor units
Governs `Currency::minor_units`, which sets the smallest unit a ledger
books in (rules.md B-3): foreign-currency lines are rounded to it.
Source: ISO 4217 "list one" (current currency & funds), published
2026-09-17 by SIX Financial Information, the ISO 4217 maintenance agency.
<https://www.six-group.com/dam/download/financial-information/data-center/iso-currrency/lists/list-one.xml>,
retrieved 2026-10-13, SHA-256
`33139b438657d1cee116ba737807ea71d19d6de4b90f799a09c56f0cc6a1b0ff`.
The list itself is ISO's, so it is not reproduced here; these are the facts
the code uses, generated from it (the `CcyMnrUnts` of each `Ccy`):
| Minor units | Currencies |
|---|---|
| 0 | BIF CLP DJF GNF ISK JPY KMF KRW PYG RWF UGX UYI VND VUV XAF XOF XPF |
| 3 | BHD IQD JOD KWD LYD OMR TND |
| 4 | CLF UYW |
| none (N.A.) | XAG XAU XBA XBB XBC XBD XDR XPD XPT XSU XTS XUA XXX |
| 2 | the other 139 codes |
To update: fetch the list again, regenerate the table and
`Currency::minor_units`, and record the new publication date and hash.
+6
View File
@@ -40,6 +40,12 @@ prove:
fi
echo "Lean: $(wc -l <<<"$audit") headline theorems, standard axioms only"
# Cross-check against GnuCash (an oracle, never the spec): random books through
# wanderbooks and through GnuCash 5.10 in a pinned container. Needs Docker.
oracle:
docker build -q -t wanderbooks-gnucash-oracle oracle/gnucash
cargo test -p ledger-core --test oracle_gnucash -- --ignored --nocapture
# Build the release binaries (CLI, MCP server, GUI)
build:
cargo build --release -p ledger-cli -p ledger-mcp -p ledger-gui
+10
View File
@@ -0,0 +1,10 @@
# GnuCash as a cross-check oracle (docs/oracle.md, "Who is right"): one
# independent implementation of double entry we diff against. Its answers
# are evidence, never the spec. Pinned so every run uses the same GnuCash.
FROM debian@sha256:a29215f6a35e51e22adffa17f89e9d2ef06214e64a2bad10d765c46aea49f11f
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
gnucash=1:5.10-0.1+b1 python3-gnucash=1:5.10-0.1+b1 \
&& rm -rf /var/lib/apt/lists/*
COPY books.py /oracle/books.py
ENTRYPOINT ["python3", "/oracle/books.py"]
+96
View File
@@ -0,0 +1,96 @@
"""Build books in GnuCash and report its balances.
stdin: a JSON list of books:
{"accounts": [{"id": 1, "code": "1000", "name": "Cash", "type": "Asset"}],
"entries": [{"date": "2026-01-15", "description": "...",
"lines": [{"account": 1, "side": "debit", "amount": "12.34"}]}],
"as_of": ["2026-01-31", ...]}
stdout: a JSON list, one per book:
{"balances": {"2026-01-31": {"1": "12.34", ...}, ...}}
Balances are GnuCash's own: the sum of split values on the account through
the end of that day, debits positive, as an exact decimal string.
"""
import datetime, json, os, sys, tempfile
from decimal import Decimal
import gnucash
from gnucash import Account, GncNumeric, Session, SessionOpenMode, Split, Transaction
TYPES = {
"Asset": gnucash.ACCT_TYPE_ASSET,
"Liability": gnucash.ACCT_TYPE_LIABILITY,
"Equity": gnucash.ACCT_TYPE_EQUITY,
"Revenue": gnucash.ACCT_TYPE_INCOME,
"Expense": gnucash.ACCT_TYPE_EXPENSE,
}
def numeric(amount: str) -> GncNumeric:
d = Decimal(amount)
sign, digits, exp = d.as_tuple()
denom = 10 ** max(0, -exp)
num = int(d * denom)
return GncNumeric(num, denom)
def decimal_of(n) -> str:
return str(Decimal(n.num()) / Decimal(n.denom()))
def end_of_day(date: str) -> int:
d = datetime.date.fromisoformat(date)
return int(datetime.datetime(d.year, d.month, d.day, 23, 59, 59,
tzinfo=datetime.timezone.utc).timestamp())
def run(book_spec, path):
s = Session(f"xml://{path}", SessionOpenMode.SESSION_NEW_STORE)
try:
book = s.book
root = book.get_root_account()
usd = book.get_table().lookup("CURRENCY", "USD")
accounts = {}
for a in book_spec["accounts"]:
acct = Account(book)
acct.SetName(f'{a["code"]} {a["name"]}')
acct.SetCode(a["code"])
acct.SetType(TYPES[a["type"]])
acct.SetCommodity(usd)
root.append_child(acct)
accounts[a["id"]] = acct
for e in book_spec["entries"]:
d = datetime.date.fromisoformat(e["date"])
tx = Transaction(book)
tx.BeginEdit()
tx.SetCurrency(usd)
tx.SetDate(d.day, d.month, d.year)
tx.SetDescription(e.get("description", ""))
for line in e["lines"]:
value = numeric(line["amount"])
if line["side"] == "credit":
value = value.neg()
sp = Split(book)
sp.SetParent(tx)
sp.SetAccount(accounts[line["account"]])
sp.SetValue(value)
sp.SetAmount(value)
tx.CommitEdit()
out = {}
for date in book_spec["as_of"]:
t = end_of_day(date)
out[date] = {str(i): decimal_of(a.GetBalanceAsOfDate(t)) for i, a in accounts.items()}
return {"balances": out}
finally:
s.end()
def main():
books = json.load(sys.stdin)
results = []
with tempfile.TemporaryDirectory() as tmp:
for i, b in enumerate(books):
results.append(run(b, os.path.join(tmp, f"book{i}.gnucash")))
json.dump(results, sys.stdout)
if __name__ == "__main__":
main()