Skip to content

Commit 5274f64

Browse files
authored
test(differential): booking + balance + clamp oracle vs beancount (C1) (#210)
* test(differential): validate booking against beancount as ground-truth oracle Snapshot tests only prove output didn't change, not that it's correct — a booking bug gets frozen into the snapshot (as the pre-beancount#1656 trial_balance snapshot did). This adds Layer 1 of the correctness plan: load each fixture through both rustfava (rustledger engine) and beancount and assert the booked per-account inventories agree to the cent. - test_booked_inventories_match_beancount over example/long-example/ query-example/off-by-one — all currently match (long-example alone has ~193 cost lots), so this locks in booking correctness going forward. - test_failing_balance_assertion_is_surfaced — xfail(rustledger#1663): the load() path emits no error for a failing `balance` (only validate() does), so a wrong assertion is silently shown as passed. Documents the defect as an executable check; flips to passing when the engine surfaces balance errors. beancount is already a test dependency (beancount-compat extra). * test(differential): C1 now passes on v0.18.0; add clamped-total oracle - test_failing_balance_assertion_is_surfaced: flip from xfail to a positive assertion — v0.18.0 (rustledger 3.1.0 / beancount#1663) reports balance failures on load and carries a non-zero diff, closing the C1 gap end to end. - test_passing_balance_assertion_is_green: guard the zero-diff mapping (a passing balance must stay diff_amount=None, not render as failed). - test_clamped_totals_match_beancount_balance_at_cutoff: the time-filter (engine clamp) opening balances must reconstruct the correct closing totals — the path rustledger#1656 broke. Clamp long-example to 2014 and assert each account's total (opening + in-window) equals beancount's balance as of the cutoff, per (currency, cost) lot. Excludes the clamp-specific Equity:Opening-Balances.
1 parent 0d7e9cc commit 5274f64

1 file changed

Lines changed: 209 additions & 0 deletions

File tree

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
"""Differential correctness tests: rustfava (rustledger engine) vs beancount.
2+
3+
rustfava aims for beancount compatibility, so beancount's own loader is a
4+
ground-truth *oracle* that the snapshot tests lack — a snapshot only proves the
5+
output did not change, not that it is correct. These tests load the same ledger
6+
through both engines and assert the booked results agree, so a booking or
7+
balance-handling divergence fails loudly instead of being frozen into a
8+
snapshot.
9+
10+
This is Layer 1 of the correctness testing plan. Start here when a report shows
11+
a wrong number: if the booked inventories already differ from beancount, the
12+
bug is in loading/booking, not in the report layer.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import datetime
18+
from collections import defaultdict
19+
from decimal import Decimal
20+
from pathlib import Path
21+
from typing import TYPE_CHECKING
22+
23+
import pytest
24+
25+
# beancount is the ground-truth oracle (installed via the `beancount-compat`
26+
# test extra). Skip cleanly rather than fail if it is somehow absent.
27+
beancount_loader = pytest.importorskip("beancount.loader")
28+
29+
from rustfava.rustledger import loader as rf_loader # noqa: E402
30+
from rustfava.rustledger.backend import get_engine # noqa: E402
31+
from rustfava.rustledger.types import directives_from_json # noqa: E402
32+
from rustfava.rustledger.types import directives_to_json # noqa: E402
33+
34+
if TYPE_CHECKING:
35+
from collections.abc import Iterable
36+
37+
DATA = Path(__file__).parent / "data"
38+
39+
# Fixtures whose booking rustfava must reproduce exactly. These span
40+
# held-at-cost lots, prices, multiple currencies, a pad/balance pair (example)
41+
# and a date-boundary case (off-by-one); long-example alone has ~193 cost lots.
42+
LEDGERS = ["example", "long-example", "query-example", "off-by-one"]
43+
44+
# Type of `cost` normalized to beancount's 4-tuple identity — deliberately
45+
# dropping rustfava's extra ``number_total`` field so two engines' economically
46+
# equal lots compare equal here (that extra field leaking into lot identity is
47+
# a separate defect; this oracle should not be sensitive to it).
48+
CostKey = tuple[Decimal | None, str | None, object, str | None]
49+
AccountInventory = dict[tuple[str | None, CostKey | None], Decimal]
50+
51+
52+
def _cost_key(cost: object) -> CostKey | None:
53+
if cost is None:
54+
return None
55+
number = getattr(cost, "number", None)
56+
return (
57+
Decimal(number) if number is not None else None,
58+
getattr(cost, "currency", None),
59+
getattr(cost, "date", None),
60+
getattr(cost, "label", None),
61+
)
62+
63+
64+
def _account_inventories(
65+
entries: Iterable[object],
66+
) -> dict[str, AccountInventory]:
67+
"""Fold every posting into ``{account: {(currency, cost): Decimal}}``.
68+
69+
Works for both engines because rustfava registers its directive/posting
70+
types against beancount's ABCs, so the ``postings``/``units``/``cost``
71+
attribute shape is identical.
72+
"""
73+
inv: dict[str, AccountInventory] = defaultdict(
74+
lambda: defaultdict(Decimal)
75+
)
76+
for entry in entries:
77+
for posting in getattr(entry, "postings", []):
78+
units = posting.units
79+
if units is None or units.number is None:
80+
continue
81+
key = (units.currency, _cost_key(posting.cost))
82+
inv[posting.account][key] += Decimal(units.number)
83+
# Drop keys/accounts that net to exactly zero.
84+
return {
85+
account: {k: v for k, v in lots.items() if v != 0}
86+
for account, lots in inv.items()
87+
if any(v != 0 for v in lots.values())
88+
}
89+
90+
91+
@pytest.mark.parametrize("ledger", LEDGERS)
92+
def test_booked_inventories_match_beancount(ledger: str) -> None:
93+
"""Per-account booked inventories must equal beancount's, to the cent."""
94+
path = str(DATA / f"{ledger}.beancount")
95+
bc_entries, _bc_errors, _ = beancount_loader.load_file(path)
96+
rf_entries, _rf_errors, _ = rf_loader.load_uncached(path)
97+
98+
bc_inv = _account_inventories(bc_entries)
99+
rf_inv = _account_inventories(rf_entries)
100+
101+
# Compare per account for a readable diff on failure.
102+
assert set(rf_inv) == set(bc_inv), (
103+
f"account set differs: only-rustfava={set(rf_inv) - set(bc_inv)}, "
104+
f"only-beancount={set(bc_inv) - set(rf_inv)}"
105+
)
106+
for account in sorted(bc_inv):
107+
assert rf_inv[account] == bc_inv[account], (
108+
f"inventory mismatch for {account}: "
109+
f"rustfava={rf_inv[account]} beancount={bc_inv[account]}"
110+
)
111+
112+
113+
def _balance_dirs(entries: Iterable[object]) -> list[object]:
114+
return [e for e in entries if type(e).__name__ in {"Balance", "RLBalance"}]
115+
116+
117+
def test_failing_balance_assertion_is_surfaced() -> None:
118+
"""A failing `balance` must produce an error AND a non-zero diff.
119+
120+
Regression for rustledger#1663 / the C1 gap: `load()` (the display path)
121+
previously reported no error for a failing assertion, so the journal showed
122+
it as passed. rustledger 3.1.0 (v0.18.0) reports balance failures on load
123+
and carries `diff` on the directive; the journal's red/green keys off a
124+
present ``diff_amount``.
125+
"""
126+
src = (
127+
"2024-01-01 open Assets:Cash USD\n"
128+
"2024-01-01 open Expenses:X USD\n"
129+
'2024-01-02 * "t"\n'
130+
" Expenses:X 5 USD\n"
131+
" Assets:Cash\n"
132+
"2024-01-03 balance Assets:Cash 999 USD\n"
133+
)
134+
entries, errors, _ = rf_loader.load_string(src, "<differential>")
135+
assert any(
136+
"balance" in str(getattr(e, "message", e)).lower() for e in errors
137+
), "a failing balance assertion produced no error"
138+
(bal,) = _balance_dirs(entries)
139+
# Real balance is -5, asserted 999 -> non-zero diff -> renders as failed.
140+
assert bal.diff_amount is not None
141+
assert bal.diff_amount.number != 0
142+
143+
144+
def test_passing_balance_assertion_is_green() -> None:
145+
"""A passing `balance` must be silent: no error, and no diff_amount.
146+
147+
Guards the zero-diff mapping — the engine sends `diff = 0` on a passing
148+
assertion, and carrying that through would mark every passing balance
149+
``diff_amount`` (i.e. failed) in the journal.
150+
"""
151+
src = (
152+
"2024-01-01 open Assets:Cash USD\n"
153+
"2024-01-01 open Expenses:X USD\n"
154+
'2024-01-02 * "t"\n'
155+
" Expenses:X 5 USD\n"
156+
" Assets:Cash\n"
157+
"2024-01-03 balance Assets:Cash -5 USD\n"
158+
)
159+
entries, errors, _ = rf_loader.load_string(src, "<differential>")
160+
assert errors == []
161+
(bal,) = _balance_dirs(entries)
162+
assert bal.diff_amount is None
163+
164+
165+
def test_clamped_totals_match_beancount_balance_at_cutoff() -> None:
166+
"""The time-filter (engine clamp) opening balances must reconstruct the
167+
correct closing totals — the path that rustledger#1656 broke.
168+
169+
Clamping to ``[begin, end)`` yields synthesized opening balances plus
170+
in-window postings; summed per account that equals the ledger's balance as
171+
of ``end`` (every posting dated before ``end``). Cross-check that against
172+
beancount, per account and per (currency, cost) lot. The synthesized
173+
``Equity:Opening-Balances`` contra is clamp-specific, so exclude it.
174+
"""
175+
path = str(DATA / "long-example.beancount")
176+
begin, end = "2014-01-01", "2015-01-01"
177+
cutoff = datetime.date(2015, 1, 1)
178+
opening = "Equity:Opening-Balances"
179+
180+
rf_entries, _, _ = rf_loader.load_uncached(path)
181+
clamped = directives_from_json(
182+
get_engine().clamp_entries(
183+
directives_to_json(list(rf_entries)), begin, end
184+
)["entries"]
185+
)
186+
rf_inv = {
187+
a: v
188+
for a, v in _account_inventories(clamped).items()
189+
if a != opening
190+
}
191+
192+
bc_entries, _, _ = beancount_loader.load_file(path)
193+
before_cutoff = [
194+
e
195+
for e in bc_entries
196+
if getattr(e, "date", cutoff) < cutoff
197+
]
198+
bc_inv = {
199+
a: v
200+
for a, v in _account_inventories(before_cutoff).items()
201+
if a != opening
202+
}
203+
204+
assert set(rf_inv) == set(bc_inv)
205+
for account in sorted(bc_inv):
206+
assert rf_inv[account] == bc_inv[account], (
207+
f"clamped total mismatch for {account}: "
208+
f"rustfava={rf_inv[account]} beancount={bc_inv[account]}"
209+
)

0 commit comments

Comments
 (0)