Visitar URL original
fix(query): split format vs format_amount semantics — recover BQL compat (refs #1112) by robcohen · Pull Request #1113 · rustledger/rustledger · GitHub
Skip to content

fix(query): split format vs format_amount semantics — recover BQL compat (refs #1112) - #1113

Merged
robcohen merged 2 commits into
mainfrom
fix/bql-inventory-quantize
May 13, 2026
Merged

robcohen merged 2 commits into
mainfrom
fix/bql-inventory-quantize

Conversation

@robcohen

Copy link
Copy Markdown
Member

Summary

Recovers most of the 7pp BQL compat regression introduced in #1106 by splitting DisplayContext::format (scalar Number — preserve scale) from format_amount (Amount/Position/Inventory — quantize to tracked dp). Python bean-query uses two distinct renderers for these two semantic kinds; we used to route both through one path.

Refs #1112. Does not fully close it — see "Out of scope" below.

The problem

#1106 changed DisplayContext::format from "always quantize to currency's tracked dp" to "preserve over-scale arithmetic precision." That fix is correct for scalar Value::Number results (Python's DecimalRenderer carries scale through SUM and Rust now matches it — closes #1103).

But the same function was also reached by format_amount (used by Amount, Position, and Inventory text rendering). Python uses a different renderer (AmountRenderer) there, which quantizes to the currency's display dp. After #1106, Rust started emitting Inventory contents with raw arithmetic scale where Python rendered at 2dp:

SELECT account, SUM(position) GROUP BY account ...

before #1106 (correct):  Equity:Opening-Balances -1202.01 USD
after #1106  (broken):   Equity:Opening-Balances -1202.00896 USD
Python (target):         Equity:Opening-Balances -1202.01 USD

Same shape for cost-spec values:

before #1106:  1.763 VIIIX { 170.16 USD}
after #1106:   1.763 VIIIX { 170.16449234259784458309699376 USD}
Python:        1.763 VIIIX { 170.16 USD}

What this PR changes

crates/rustledger-core/src/display_context.rs

crates/rustledger/src/cmd/query/output.rs

  • Value::Position and Value::Inventory numberify branches call format_amount_number instead of format. So when the user strips the currency suffix, the number still quantizes to the same dp it would have shown with the suffix.

Tests

Two new unit tests in display_context.rs:

  • test_format_vs_format_amount_split_semantics — pins that format preserves scale and format_amount quantizes, both on the canonical -1202.00896 case and the 26-digit cost-spec case (170.16449...).
  • test_format_amount_untracked_currency_uses_natural_scale — pins the fallback path for currencies without tracked precision.

Verification

Local reproduction of the failing fixtures (build with --no-default-features to match CI compat-test build):

Fixture / query Before After (this PR) Python target
ofx_cleared_0_journal sum-position-by-account -1202.00896 USD -1202.01 USD -1202.01 USD
healthequity_invalid_journal sum-position-by-account 185.58392… USD 185.58 USD 185.58 USD
vanguard_with_cash_… balances-by-account 0.00000 USD 0.00 USD 0.00 USD
ofx_cleared_0_journal SUM(number) GROUP BY currency -1202.00896 USD -1202.00896 USD -1202.00896 USD (#1103 fix intact)

Out of scope

Two non-scale failures remain after this fix, with different root causes:

  1. cost_only.beancount first-balance-by-month and similar first-balance-by-month queries — Rust shows an extra residual posting (e.g. -5200 USD) that Python doesn't. Different bug, separate fix.
  2. healthequity sum-number-by-currency — Rust's intermediate SUM produces scale 2 where Python's produces scale 3. The format path is correct (preserve whatever scale arrives); the issue is upstream in arithmetic. Continuation of fix(booking): drop cost-spec scale from interpolation rounding (#1107) #1108's "fix Rust pipeline scale propagation" work.

Both tracked under #1112.

Test plan

  • cargo test -p rustledger-core --all-features — 335 tests pass
  • cargo test -p rustledger --all-features --lib output — 31 output tests pass
  • cargo clippy -p rustledger-core -p rustledger --all-features --all-targets -- -D warnings clean
  • cargo fmt --all -- --check clean
  • Pre-push workspace-wide cargo check passed
  • Manual: each canonical Bucket B / Bucket C fixture from BQL compat: two residual divergences (#1106 regression resolved in #1113) #1112 now matches Python output

🤖 Generated with Claude Code

…pat regression from #1106)

Python `bean-query` uses two distinct renderers:

- `DecimalRenderer` for scalar `Value::Number` — preserves the Decimal's
  arithmetic scale, since Python `decimal` carries scale through SUM.
- `AmountRenderer` for Amounts/Positions/Inventory entries — quantizes to
  the currency's tracked display precision (the user-facing "USD renders
  at 2dp" setting).

Rust used to route both through `DisplayContext::format`. When #1106 fixed
the scalar-Number case (correctly making `format` preserve scale, closing
#1103), the change inadvertently regressed Amount/Position/Inventory
output because they share the same code path. Inventory amounts now
rendered with raw 5-26 digit arithmetic scale (`-1202.00896 USD`,
`{ 170.16449234259784458309699376 USD}`) where bean-query renders
quantized (`-1202.01 USD`, `{ 170.16 USD}`). BQL compat dropped from 99%
to 92% — see #1112 for the regression analysis.

This commit:

* Adds `DisplayContext::format_amount_number(n, ccy)` and an internal
  `format_quantized` helper. `format_amount` now goes through
  `format_quantized` directly instead of calling `format`, restoring
  the pre-#1106 quantize-and-pad behavior for Amount-typed values.
* `format` itself is unchanged — scalar `Value::Number` continues to
  preserve scale (the #1103 fix stays).
* `output.rs`: the `Value::Position` and `Value::Inventory` `numberify`
  branches now call `format_amount_number` instead of `format`, so the
  stripped-currency rendering also quantizes correctly.

Verified locally with `--no-default-features` (matching the CI compat
test build) against the fixtures originally listed in the failure log:

  testdata_reconcile_test_ofx_cleared_0_journal.beancount sum-position-by-account
    before: Equity:Opening-Balances -1202.00896 USD
    after:  Equity:Opening-Balances -1202.01 USD       (matches Python)

  testdata_source_healthequity_test_invalid_journal.beancount sum-position-by-account
    before: 10.436 VIIIX { 185.58392... USD}
    after:  10.436 VIIIX { 185.58 USD}                 (matches Python)

Scalar SUM(number) still preserves scale on the same fixture:

  SELECT currency, SUM(number) GROUP BY currency
    USD -1202.00896          (matches Python, #1103 fix intact)

Tests:

* Two new unit tests in display_context.rs pin the split semantics
  (`test_format_vs_format_amount_split_semantics` and
  `test_format_amount_untracked_currency_uses_natural_scale`).
* All 335 rustledger-core unit tests pass.
* All 31 cmd::query::output tests pass.
* `cargo clippy -p rustledger-core -p rustledger --all-features --all-targets -- -D warnings` clean.

Refs #1112. Does not close it — the remaining ~5 mismatches (cost_only's
`first-balance-by-month` extra-posting bug; healthequity's `sum-number-by-currency`
upstream-scale issue) have different root causes and will need separate
follow-ups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 13, 2026 20:07
@robcohen
robcohen requested a review from a team as a code owner May 13, 2026 20:07
@codecov

codecov Bot commented May 13, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.11321% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/rustledger-core/src/display_context.rs 97.22% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR separates scalar decimal formatting from amount-style formatting so BQL output can preserve precision for naked Value::Number results while quantizing Amount/Position/Inventory renderings to tracked currency precision.

Changes:

  • Adds quantized amount formatting helpers in DisplayContext.
  • Updates numberified Position and Inventory output paths to use amount-style quantization.
  • Adds unit tests covering split formatting semantics and untracked currency behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
crates/rustledger-core/src/display_context.rs Adds quantized formatting helper and tests for scalar vs amount rendering semantics.
crates/rustledger/src/cmd/query/output.rs Routes numberified Position/Inventory rendering through quantized amount-number formatting.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@github-actions

github-actions Bot commented May 13, 2026 •

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

Tool Time Memory
rustledger 32.8ms ± 0.5ms 21.9 MB
beancount 741.6ms ± 3.3ms 50.5 MB
Comparison 22.6x faster 2.3x less

vs main branch (2026-05-13)

Metric main this PR Change
Time 32.4ms 32.8ms +1.2% ✅
10K transactions · 5 runs, 2 warmup · hyperfine
What do these numbers mean?
  • Time: Wall-clock time to parse and validate the ledger (± std dev)
  • Memory: Peak resident set size (RSS)
  • vs main: Compared to latest nightly benchmark on main branch

@github-actions

github-actions Bot commented May 13, 2026 •

Copy link
Copy Markdown
Contributor

✅ Compatibility Test Results

Metric Result
Check Compatibility 100%
BQL Compatibility 99%
Full AST Match 99%
Total Files 786
What's tested
  • Check: Exit codes and error counts match bean-check
  • BQL: Query results match bean-query
  • Files: ~800 test files downloaded from 10+ beancount repositories

Full results available in workflow artifacts

…number (#1113 review)

Copilot review on #1113 caught that the `Value::Amount` numberify branch
was left on `ctx.format(...)` while `Value::Position` and `Value::Inventory`
were moved to `format_amount_number`. That left an inconsistency where an
amount-typed cell rendered with `--numberify` would preserve over-scale
arithmetic precision while the same cell without `--numberify` would
quantize.

Route `Value::Amount` through `format_amount_number` too, so all three
amount kinds quantize consistently under `--numberify`.

Adds `test_numberify_quantizes_all_amount_kinds_consistently` that pins
the contract: same `-1202.00896 USD` value rendered through Amount,
Position, and Inventory must all produce `-1202.01` under `--numberify`
when USD is tracked at 2dp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@robcohen
robcohen merged commit f974106 into main May 13, 2026
42 checks passed
@robcohen
robcohen deleted the fix/bql-inventory-quantize branch May 13, 2026 20:20
robcohen added a commit that referenced this pull request May 14, 2026
…Rust divergences (refs #1112) (#1114)

* chore(compat): mask the last 3 BQL divergences from #1112

After #1113 recovered the bulk of the #1106 regression, three real
mismatches remain — all `sum-number-by-currency` queries on fixtures
whose cost-spec interpolation produces a scale-3 residual in Python's
pipeline vs scale-2 in rledger's:

* testdata_source_healthequity_test_invalid_journal.beancount
* testdata_source_healthequity_test_matching_journal.beancount
* testdata_source_ofx_test_non_default_capital_gains_journal.beancount

In every case the numeric value is the same (e.g. `-1966.700` vs
`-1966.70`) — only the display scale differs, surfaced as a textual
diff because both tools intentionally preserve scale on
`Value::Number` output.

Root cause: cost-spec interpolation against `{}` lot-match against a
`{{total}}` lot produces a residual whose scale depends on which
intermediate value drives it. Python's intermediate stays at the
buy-side scale 3; rledger's #1108 fix dropped intermediate scale to
the input minimum (2) to stop the 26-digit contamination. That fix
was correct for the over-precision case but over-applies here.

Mask surgically (per `(file, query_name)`, not `"*"`) so any
non-scale divergence on these fixtures stays surfaced. This is a
display-scale shim, not a value bug — both tools agree on the
arithmetic. Fixing it properly means continuing #1108's pipeline
scale-propagation work, which is tracked under #1112 as a follow-up.

After this commit all 5 `first-balance-by-month` Python beanquery
bugs (#1105) and the 3 scale residuals here are accounted for as
known divergences, taking the BQL compat percentage's effective
match closer to 100%. The user-facing rounded percentage stays at
99% (it was already 99% post-#1113); this commit cleans up the
remaining bookkeeping so future regressions stand out clearly.

Refs #1112.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(compat): split known-Python vs known-Rust BQL divergences (#1114 review)

Copilot review caught that the 3 `sum-number-by-currency` entries I
added to `KNOWN_PYTHON_DIVERGENCES` are actually rledger-side bugs
(continuation of #1108's pipeline scale-propagation work), not Python
beanquery quirks. Conflating them in the Python allowlist lets an
rledger bug masquerade as a Python quirk and could absorb a future
rledger regression on these file/query pairs.

Fix: introduce a separate `KNOWN_RUST_DIVERGENCES` set with the same
surgical-pin semantics. Wire it through:

- New predicate `_is_known_rust_divergence(run)` mirroring the Python
  one. New `_is_known_divergence(run)` returns the OR of both.
- Summary stats report `Known Python diffs` and `Known Rust diffs`
  separately so each side's allowlist size is visible at a glance.
- Per-mismatch log lines use `KNOWN-PY` / `KNOWN-RS` / `MISMATCH`
  labels.
- `$GITHUB_OUTPUT` gets two new keys, `bql_known_python` and
  `bql_known_rust`, alongside the existing aggregate `bql_known_divergences`.
- The 3 cost-spec interpolation cases move out of the Python allowlist
  and into the new Rust one. Effective match percentage is unchanged
  (still counted as "known"); only the bookkeeping is corrected.

Also addresses Copilot's second comment: the inline doc on the Rust
allowlist now says "Tracked under #1112 (kept open as the tracker — do
not auto-close from this PR)" so the reference stays valid post-merge.
PR body updated correspondingly.

Refs #1112. Does NOT auto-close it — the deep pipeline fix is the
real closure path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(booking): prune zero-value interpolated postings post-validation

Until now rledger preserved every interpolated posting through to user-
facing output, including zero-value ones (commit 7706be9, closes #877).
That fix was correct in spirit — pruning before validation hides E1001
on never-opened accounts referenced by elided postings — but
over-applied: it kept zero postings in user-facing output too, where
Python beancount drops them. The result was a persistent ~0.25% Full
AST Match floor on fixtures like `tests/regressions/issue-276.beancount`
and `tests/regressions/issue-279.beancount` (multi-posting lot sales
with zero residuals on Income:Trading).

This commit splits the two invariants by phase:

1. Booking marks every interpolated posting with a new metadata key
   `__interpolated__` (exposed as `rustledger_booking::INTERPOLATED_MARKER`).
   Validation runs against transactions that still include zero-value
   postings — so #877's E1001-on-unopened-account check still fires.

2. AFTER validation, `process()` walks each transaction and drops
   postings that have the marker AND a zero `units` value. Survivors
   keep their value but the marker is stripped so it doesn't leak into
   BQL queries, JSON output, or formatted ledgers.

Both invariants verified by new integration tests in
`crates/rustledger-loader/tests/loader_test.rs`:

* `test_zero_interpolated_posting_pruned_on_opened_account` — zero-value
  interpolated Income posting is dropped from output, no errors.
* `test_zero_interpolated_posting_keeps_e1001_on_unopened_account` —
  the #877 scenario: E1001 still emitted.
* `test_non_zero_interpolated_posting_is_preserved` — only ZERO ones
  get pruned; legitimate interpolated residuals stay.
* `test_interpolated_marker_does_not_leak_to_user_output` — defensive
  check that `__interpolated__` is stripped from survivors.

Effect on compat suite (verified locally with `--no-default-features`,
matching CI):

  Before                                             After
  ─────────────────────────────────────────────      ─────────────────────────
  issue-276.beancount  SELECT COUNT(*) = 12          = 10  (matches Python)
  issue-279.beancount  SELECT COUNT(*) = 13          = 11  (matches Python)
  Full AST Match       99% (784/786)                 100% (786/786)

The #877 regression case still produces E1001 with this commit applied:

  $ rledger check zero-balance.bean
  E1001 Account Expenses:NeverOpened was never opened
  ✗ 1 error
  exit_code: 1

Closes the residual AST divergence tracked under #1112.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
robcohen added a commit that referenced this pull request Jul 10, 2026
* refactor: single-source the posting weight arithmetic in booking

The Beancount posting-weight rule (cost beats price; preserved totals;
@@ sign follows units) existed three times: the canonical generic
ladder in rustledger-booking (shared by the residual and interpolation
since #1700), a copy in the query executor's compute_posting_weight
(the BQL weight column), and a copy in the currency_accounts plugin's
weight_of closure. The copies had already sprouted real divergences
from the canonical:

- query recomputed total-cost weights as units x per_unit via
  CostSpec::resolve, reintroducing the division-then-multiplication
  precision loss the booking ladder deliberately avoids by using the
  preserved total (#1106/#1113): SELECT weight for a booked
  {{100.00 USD}} over 3 units showed 99.99999999999999999999999999
  where rledger check balances with 100.00 exactly.
- the plugin took abs() of totals and @@ prices before re-signing,
  silently "fixing" negative magnitudes instead of following the
  canonical total x signum arithmetic.

Expose the ladder from booking as two public Decimal functions backed
by the existing generic (WeightNum) implementations — no second copy:

- cost_number_weight(units, &CostNumber): the four-variant cost
  arithmetic (PerUnit multiply, Total/PerUnitFromTotal preserved total
  x signum, Compound N*a+b).
- price_weight(units, price, PriceKind): @ = |units| x price x signum,
  @@ = price x signum (the #1052 credit-side flip; zero units weigh
  zero for both kinds).

residual_weight's price arm now uses the same generic helper, so the
validator, BQL weight column, and currency_accounts neutralization all
compute weights through one ladder. The query's fallback order and the
plugin's DTO parsing (with its exhaustive compile-fail match, now the
CostNumberData -> CostNumber mapping) are unchanged.

New tests pin the exact-total weight through SELECT weight, all four
CostNumber variants, and the price sign rules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: regenerate cargo-vet exemptions

* docs: justify BookedCost struct literal in currency_accounts (review)

The weight arithmetic reads only .total for PerUnitFromTotal, so the
per_unit consistency invariant is irrelevant at this site; enforcing
it would need an error channel the infallible weight closure doesn't
have. Wire-pair consistency is enforced at the ingress boundary
(ffi-wasi input_entry_to_directive uses try_new).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: rustledger-bot[bot] <257329324+rustledger-bot[bot]@users.noreply.github.com>
robcohen added a commit that referenced this pull request Aug 5, 2026
…not (#1965)

* fix(query): stop WEIGHT() inventing precision the weight column does not

Found by the canonical-function drift sweep (#1902 Phase 2). CLAUDE.md names
this pair specifically: a consumer surfacing a per-posting weight must call
`rustledger_booking::posting_weight` rather than re-derive it. The `weight`
COLUMN does. The `WEIGHT()` FUNCTION re-derived `units * cost.number`.

  3 HOOL {{100.00 USD}}

  weight column     100.00
  WEIGHT(position)  100.00000000000000000000000000

Same value, 26 digits of scale invented by the multiplication. `{{100.00}}`
resolves to a per-unit of 33.333..., and multiplying back reintroduces exactly
the division-then-multiplication the canonical exists to avoid (#1106/#1113).
The compat suite compares RENDERED output, so scale is not cosmetic.

WHY THIS IS A MITIGATION AND NOT A CORRECTION

`WEIGHT()` cannot call the canonical. It receives a `Value::Position`, whose
cost is already resolved to a per-unit `Decimal` - the preserved total that
makes `posting_weight` exact is gone by then - and
`evaluate_function_on_values` gets only `Value`s, with no posting to recover it
from. So the function still yields `100` where the column yields `100.00`.
Closing that needs `Position` to retain the cost spec, which is a wider change
than the symptom warrants; it is recorded on #1963.

THE OBVIOUS FIX WAS WORSE, AND IS NOW PINNED AGAINST

Normalizing unconditionally was the first attempt. It fixes the case above and
REGRESSES the common one: `2 HOOL {5.25 USD}` went from `10.50` to `10.5`,
because normalize strips meaningful trailing zeros too. Trading a rare cosmetic
problem for a frequent one is not a fix.

So the strip applies only when the scale is already past anything a real cost
carries (>12). Such a scale can only come from a division; `rust_decimal` tops
out at 28, and no written cost has 12 decimal places.

Both tests verified to fail on their own sabotage:

  raw product           -> "WEIGHT() invented precision: column=100.00 ..."
  unconditional normalize -> "on a plain per-unit cost the function and the
                              column must match exactly"

The second is the one worth having: it is the guard against re-attempting the
fix I nearly shipped.

Closes #1963.

* fix(query): use checked_mul in WEIGHT, and assert on scale directly

- WEIGHT() multiplied with the bare operator while the adjacent COST()
  arm uses checked_mul + overflow_err. Same product, same operands, two
  different overflow behaviors; now both fail loudly. A saturating
  product would certify a weight that never existed, which is the
  unsoundness #1863 removed from the residual path.

- The comment claimed a scale past 12 could only have come from a
  division, never from a written number. rust_decimal accepts literals
  out to 28 places, so a user could author a 13-place cost and see its
  weight normalized. Stated as the heuristic it is, with why the
  alternative (normalizing every weight) is worse.

- The regression guard compared Debug-string lengths as a proxy for
  precision. A formatting tweak could move it with no scale changing,
  and it never named the invariant. Compares Decimal::scale() now.
  Both sabotages still fail the test: dropping the normalize (function
  scale 26 vs column 2) and applying it unconditionally (10.50 -> 10.5).

* fix(query): route both WEIGHT() cost paths through one helper

Deep review of this PR found the fix was half a fix. WEIGHT() reaches
the same units x cost arithmetic two ways - a bare Position, and every
position inside an Inventory - and the first commit patched only the
first. The Inventory arm 30 lines below kept #1963 verbatim, so on the
same 3 HOOL {{100.00 USD}} lot:

  weight column          100.00
  WEIGHT(position)       100
  WEIGHT(SUM(position))  100.00000000000000000000000000

The two WEIGHT() spellings disagreed with each other as well as with
the column. Which is the drift this sweep exists to find: the same
quantity derived twice in one function, fixed once.

Both arms now call position_cost_total(). That also consolidates the
overflow message - the Inventory arm had its own wording rather than
overflow_err() - and nothing asserts on the old text.

New test pins the two paths together; verified it reports dirty by
reverting only the Inventory arm to a bare multiply (inventory scale
26, position scale 0). Also fixed a comment referring to a length check
that the previous commit replaced with a scale assertion.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

compat(bql): SUM(number) over-quantizes when input precision exceeds currency's tracked precision

2 participants