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