Files
wanderbooks/crates/ledger-core/benches/books.rs
T
swaitsandClaude Opus 5.5 c539004d1a
check / check (push) Canceled after 0s
test: fuzzing, Miri, benchmarks, CI, and a strategy note; the journal refuses floats
Max-testing pass, ranks 4-6 and step 9 for ledger-core.

Fuzzing (rank 4): fuzz_op_sequence now drives balanced pairs, foreign
lines, periods, closes, and amounts from raw decimal bits, and asserts
the books stay valid (TB balances, posted entries validate, no draft in
a closed period); new fuzz_core_serde (every persisted core type
round-trips) and fuzz_store_open (a corrupt journal is an error, never a
panic); hangs count (-timeout=10). 54M executions; one crash:
- fuzz_journal_line: a number too big for an integer parsed as a float,
  and its canonical form (which the hash chain covers) re-canonicalized
  differently. The journal never holds floats (R-2), so the reader now
  refuses them (JournalLine::parse, used by every journal reader);
  regression a_journal_line_with_a_float_is_refused. Checked first: the
  real books' journal has none. Rerun: 13.2M executions, clean.
Seeds in fuzz/seeds/ (synthetic, minimal); the grown corpora stay out of
git. `just fuzz <target> <seconds>`.

Miri (rank 4): the core's unit, bridge, and invariant tests under Miri
(our code has no unsafe; rust_decimal and chrono do): no undefined
behavior. `just miri`. A pinned nightly (nightly-2026-10-01, with miri)
is now in mise.toml.

Benchmarks (rank 6): criterion (new dev-dependency: the playbook's Rust
benchmark tool; none existed) in benches/books.rs; baselines in
docs/testing.md. The store's per-command rehearsal clone costs ~1 ms at
10k entries. No budget yet, so nothing gates on them. `just bench`.

CI: .github/workflows/check.yml runs `just check` on every push;
scheduled.yml runs proofs, the GnuCash oracle, fuzzing, and mutation
weekly. Tools come from mise.toml.

docs/testing.md: every layer, its oracle rank, command, runtime, and
gate; benchmark baselines; the ASSUMED invariants for Stephen to reject;
the gap list with triggers. docs/oracle.md's fuzzing paragraph had
claimed persistent corpora that didn't exist; corrected.

Also: ledger-import now inherits the workspace lints (nightly cargo
warned).

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

95 lines
3.5 KiB
Rust

//! Benchmarks for the hot paths (rank 6: a budget, never correctness).
//!
//! `cargo bench -p ledger-core --bench books`. Books of 1k and 10k entries
//! over a 40-account chart: posting, cloning (the store clones the ledger
//! to rehearse every command), trial balance, balance sheet, and a
//! period's P&L. Baselines are recorded in docs/testing.md; there is no
//! budget yet, so nothing gates on them.
use chrono::NaiveDate;
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
use ledger_core::reports::{balance_sheet_as_of, profit_and_loss_for_period, trial_balance};
use ledger_core::{AccountType, Currency, Ledger, Money, Side};
use rust_decimal::Decimal;
use std::hint::black_box;
const TYPES: [AccountType; 5] = [
AccountType::Asset,
AccountType::Liability,
AccountType::Equity,
AccountType::Revenue,
AccountType::Expense,
];
/// `entries` balanced two-line entries across 2026 over 40 accounts.
fn books(entries: u64) -> Ledger {
let mut l = Ledger::new(Currency::USD);
let ids: Vec<u64> = (0..40)
.map(|i| {
l.add_account(
&format!("{}", 1000 + i),
"a",
TYPES[i as usize % 5],
None,
false,
)
.unwrap()
})
.collect();
let mut x: u64 = 0x9e37_79b9;
for n in 0..entries {
x = x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
let date =
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap() + chrono::Days::new(n * 365 / entries);
let e = l.create_entry(date, "bench");
let m = Money::new(Decimal::new((x % 1_000_000) as i64 + 1, 2), Currency::USD);
l.add_line(e, ids[(x >> 8) as usize % 40], Side::Debit, m)
.unwrap();
l.add_line(e, ids[(x >> 20) as usize % 40], Side::Credit, m)
.unwrap();
l.post_entry(e).unwrap();
}
l
}
fn bench(c: &mut Criterion) {
let end = NaiveDate::from_ymd_opt(2026, 12, 31).unwrap();
for n in [1_000u64, 10_000] {
let l = books(n);
let mut g = c.benchmark_group(format!("{n} entries"));
g.sample_size(20);
g.bench_function(BenchmarkId::new("clone (store rehearsal)", n), |b| {
b.iter(|| black_box(l.clone()))
});
g.bench_function(BenchmarkId::new("post one more entry", n), |b| {
b.iter_batched(
|| l.clone(),
|mut l| {
let e = l.create_entry(end, "one more");
let m = Money::new(Decimal::new(100, 2), Currency::USD);
l.add_line(e, 1, Side::Debit, m).unwrap();
l.add_line(e, 2, Side::Credit, m).unwrap();
l.post_entry(e).unwrap();
l
},
criterion::BatchSize::LargeInput,
)
});
g.bench_function(BenchmarkId::new("trial balance", n), |b| {
b.iter(|| black_box(trial_balance(&l)))
});
g.bench_function(BenchmarkId::new("balance sheet", n), |b| {
b.iter(|| black_box(balance_sheet_as_of(&l, end).unwrap()))
});
g.bench_function(BenchmarkId::new("one month's P&L", n), |b| {
let start = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
let stop = NaiveDate::from_ymd_opt(2026, 6, 30).unwrap();
b.iter(|| black_box(profit_and_loss_for_period(&l, start, stop, None).unwrap()))
});
g.finish();
}
}
criterion_group!(benches, bench);
criterion_main!(benches);