test: fuzzing, Miri, benchmarks, CI, and a strategy note; the journal refuses floats
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
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
This commit is contained in:
1 parent
8a9a2485cf
commit
c539004d1a
25 files changed
+1635
-81
No files matched your search
@@ -17,4 +17,9 @@ serde_json = "1"
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = "0.8.2"
|
||||
proptest = "1"
|
||||
|
||||
[[bench]]
|
||||
name = "books"
|
||||
harness = false
|
||||
@@ -0,0 +1,94 @@
|
||||
//! 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);
|
||||
@@ -17,3 +17,6 @@ typst-pdf = "0.15.1"
|
||||
typst = "0.15.1"
|
||||
serde_json = "1"
|
||||
typst-layout = "0.15.1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -72,6 +72,38 @@ fn sort_value(v: &Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
impl JournalLine {
|
||||
/// Parse one journal line, refusing what the journal never holds.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: read a journal line so that its canonical form (which the
|
||||
/// hash chain covers) is a fixed point.
|
||||
/// SOURCE: R-2 (no floats in money paths); found by fuzz_journal_line
|
||||
/// (2026-10-13): a float payload re-canonicalized differently.
|
||||
/// INVARIANTS:
|
||||
/// 1. NEVER accepts a floating-point number anywhere in the line:
|
||||
/// amounts are decimal strings and ids are integers.
|
||||
/// 2. For an accepted line, parse(canonical) re-canonicalizes to the
|
||||
/// same bytes.
|
||||
/// ```
|
||||
pub fn parse(raw: &[u8]) -> Result<JournalLine, String> {
|
||||
let value: Value = serde_json::from_slice(raw).map_err(|e| e.to_string())?;
|
||||
if has_float(&value) {
|
||||
return Err("a number with a fraction or exponent; the journal holds none".into());
|
||||
}
|
||||
serde_json::from_value(value).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn has_float(v: &Value) -> bool {
|
||||
match v {
|
||||
Value::Number(n) => n.is_f64(),
|
||||
Value::Array(a) => a.iter().any(has_float),
|
||||
Value::Object(m) => m.values().any(has_float),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Hex SHA-256 of bytes.
|
||||
pub fn sha256_hex(bytes: &[u8]) -> String {
|
||||
let mut h = Sha256::new();
|
||||
@@ -158,7 +190,7 @@ pub fn read_and_verify_journal(path: &Path) -> Result<Vec<JournalLine>, StoreErr
|
||||
let mut expected_prev = GENESIS_PREV.to_string();
|
||||
for (i, raw) in reader.lines().enumerate() {
|
||||
let raw = raw?;
|
||||
let line: JournalLine = serde_json::from_str(&raw).map_err(|e| {
|
||||
let line: JournalLine = JournalLine::parse(raw.as_bytes()).map_err(|e| {
|
||||
StoreError::JournalCorrupt(format!("seq {}: unparseable line: {e}", i + 1))
|
||||
})?;
|
||||
let want_seq = i as u64 + 1;
|
||||
@@ -204,7 +236,7 @@ pub fn truncate_to_last_good_seq(path: &Path) -> Result<u64, StoreError> {
|
||||
if line_str.trim().is_empty() {
|
||||
break; // blank line: our writer never emits these
|
||||
}
|
||||
match serde_json::from_str::<JournalLine>(line_str) {
|
||||
match JournalLine::parse(line_str.as_bytes()) {
|
||||
Ok(line) if line.seq == last_good_seq + 1 && line.prev == expected_prev => {
|
||||
expected_prev = sha256_hex(line.to_canonical().as_bytes());
|
||||
last_good_seq = line.seq;
|
||||
|
||||
@@ -118,7 +118,7 @@ impl Snapshot {
|
||||
let complete = &buf[..=last_newline];
|
||||
let mut added = 0;
|
||||
for raw in complete.split(|&b| b == b'\n').filter(|l| !l.is_empty()) {
|
||||
let line: JournalLine = serde_json::from_slice(raw).map_err(|e| {
|
||||
let line: JournalLine = JournalLine::parse(raw).map_err(|e| {
|
||||
StoreError::JournalCorrupt(format!(
|
||||
"seq {}: unparseable line: {e}",
|
||||
self.lines.len() + 1
|
||||
|
||||
@@ -1065,3 +1065,26 @@ fn books_written_under_older_rules_still_open() {
|
||||
"108.34567".parse::<Decimal>().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression (fuzz_journal_line, 2026-10-13, crash-c9fa20f3): a journal
|
||||
/// line holding a number too large 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; float-free lines are a fixed point.
|
||||
#[test]
|
||||
fn a_journal_line_with_a_float_is_refused() {
|
||||
use ledger_store::JournalLine;
|
||||
let crash = br#"{"payload":4444454444444444404444,"prev":"x","seq":8,"ts":";u","type":""}"#;
|
||||
assert!(JournalLine::parse(crash).is_err());
|
||||
assert!(
|
||||
JournalLine::parse(br#"{"payload":{"amount":1.5},"prev":"x","seq":1,"ts":"t","type":"a"}"#)
|
||||
.is_err()
|
||||
);
|
||||
let ok = br#"{"payload":{"amount":"1.50","id":7},"prev":"x","seq":1,"ts":"t","type":"a"}"#;
|
||||
let line = JournalLine::parse(ok).unwrap();
|
||||
let canon = line.to_canonical();
|
||||
assert_eq!(
|
||||
JournalLine::parse(canon.as_bytes()).unwrap().to_canonical(),
|
||||
canon
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user