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:
1 parent
0d4a5f8f53
commit
beb1fcf233
20 files changed
+8110
-51
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user