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
@@ -0,0 +1,10 @@
|
||||
# GnuCash as a cross-check oracle (docs/oracle.md, "Who is right"): one
|
||||
# independent implementation of double entry we diff against. Its answers
|
||||
# are evidence, never the spec. Pinned so every run uses the same GnuCash.
|
||||
FROM debian@sha256:a29215f6a35e51e22adffa17f89e9d2ef06214e64a2bad10d765c46aea49f11f
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
gnucash=1:5.10-0.1+b1 python3-gnucash=1:5.10-0.1+b1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY books.py /oracle/books.py
|
||||
ENTRYPOINT ["python3", "/oracle/books.py"]
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Build books in GnuCash and report its balances.
|
||||
|
||||
stdin: a JSON list of books:
|
||||
{"accounts": [{"id": 1, "code": "1000", "name": "Cash", "type": "Asset"}],
|
||||
"entries": [{"date": "2026-01-15", "description": "...",
|
||||
"lines": [{"account": 1, "side": "debit", "amount": "12.34"}]}],
|
||||
"as_of": ["2026-01-31", ...]}
|
||||
stdout: a JSON list, one per book:
|
||||
{"balances": {"2026-01-31": {"1": "12.34", ...}, ...}}
|
||||
Balances are GnuCash's own: the sum of split values on the account through
|
||||
the end of that day, debits positive, as an exact decimal string.
|
||||
"""
|
||||
import datetime, json, os, sys, tempfile
|
||||
from decimal import Decimal
|
||||
import gnucash
|
||||
from gnucash import Account, GncNumeric, Session, SessionOpenMode, Split, Transaction
|
||||
|
||||
TYPES = {
|
||||
"Asset": gnucash.ACCT_TYPE_ASSET,
|
||||
"Liability": gnucash.ACCT_TYPE_LIABILITY,
|
||||
"Equity": gnucash.ACCT_TYPE_EQUITY,
|
||||
"Revenue": gnucash.ACCT_TYPE_INCOME,
|
||||
"Expense": gnucash.ACCT_TYPE_EXPENSE,
|
||||
}
|
||||
|
||||
|
||||
def numeric(amount: str) -> GncNumeric:
|
||||
d = Decimal(amount)
|
||||
sign, digits, exp = d.as_tuple()
|
||||
denom = 10 ** max(0, -exp)
|
||||
num = int(d * denom)
|
||||
return GncNumeric(num, denom)
|
||||
|
||||
|
||||
def decimal_of(n) -> str:
|
||||
return str(Decimal(n.num()) / Decimal(n.denom()))
|
||||
|
||||
|
||||
def end_of_day(date: str) -> int:
|
||||
d = datetime.date.fromisoformat(date)
|
||||
return int(datetime.datetime(d.year, d.month, d.day, 23, 59, 59,
|
||||
tzinfo=datetime.timezone.utc).timestamp())
|
||||
|
||||
|
||||
def run(book_spec, path):
|
||||
s = Session(f"xml://{path}", SessionOpenMode.SESSION_NEW_STORE)
|
||||
try:
|
||||
book = s.book
|
||||
root = book.get_root_account()
|
||||
usd = book.get_table().lookup("CURRENCY", "USD")
|
||||
accounts = {}
|
||||
for a in book_spec["accounts"]:
|
||||
acct = Account(book)
|
||||
acct.SetName(f'{a["code"]} {a["name"]}')
|
||||
acct.SetCode(a["code"])
|
||||
acct.SetType(TYPES[a["type"]])
|
||||
acct.SetCommodity(usd)
|
||||
root.append_child(acct)
|
||||
accounts[a["id"]] = acct
|
||||
for e in book_spec["entries"]:
|
||||
d = datetime.date.fromisoformat(e["date"])
|
||||
tx = Transaction(book)
|
||||
tx.BeginEdit()
|
||||
tx.SetCurrency(usd)
|
||||
tx.SetDate(d.day, d.month, d.year)
|
||||
tx.SetDescription(e.get("description", ""))
|
||||
for line in e["lines"]:
|
||||
value = numeric(line["amount"])
|
||||
if line["side"] == "credit":
|
||||
value = value.neg()
|
||||
sp = Split(book)
|
||||
sp.SetParent(tx)
|
||||
sp.SetAccount(accounts[line["account"]])
|
||||
sp.SetValue(value)
|
||||
sp.SetAmount(value)
|
||||
tx.CommitEdit()
|
||||
out = {}
|
||||
for date in book_spec["as_of"]:
|
||||
t = end_of_day(date)
|
||||
out[date] = {str(i): decimal_of(a.GetBalanceAsOfDate(t)) for i, a in accounts.items()}
|
||||
return {"balances": out}
|
||||
finally:
|
||||
s.end()
|
||||
|
||||
|
||||
def main():
|
||||
books = json.load(sys.stdin)
|
||||
results = []
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for i, b in enumerate(books):
|
||||
results.append(run(b, os.path.join(tmp, f"book{i}.gnucash")))
|
||||
json.dump(results, sys.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user