Visitar URL original
fix(parser,booking)!: compound cost {a # b} carries both components (N*a + b) by robcohen · Pull Request #1704 · rustledger/rustledger · GitHub
Skip to content

fix(parser,booking)!: compound cost {a # b} carries both components (N*a + b) - #1704

Merged
robcohen merged 5 commits into
mainfrom
fix/compound-cost-weight
Jul 3, 2026
Merged

robcohen merged 5 commits into
mainfrom
fix/compound-cost-weight

Conversation

@robcohen

@robcohen robcohen commented Jul 3, 2026

Copy link
Copy Markdown
Member

Fixes the last and most severe route-smoke finding, per the design discussed on #1700.

The defect (all four table rows repro'd)

The parser folded {a # b} into {{b}} — dropping a on an explicitly wrong premise — and mis-read {# b} as PerUnit{b} ({# lexes as a fused L_BRACE_HASH the old detector missed). Valid compound ledgers errored, invalid ones balanced silently, and lot cost bases were wrong whenever N·a + b ≠ b (the issue's example coincidentally masked this).

The fix (design option A)

New CostNumber::Compound{per_unit, total} carried as written (units may interpolate later). Booking folds it into the existing Total→PerUnitFromTotal conversion with effective_total = N·a + b — every post-booking consumer is form-agnostic. Both residual tiers weigh N·a + b through the shared generic arm.

Reach (the #1164 exhaustive-enum design at work)

The compiler walked the change to 12 consumer sites across 8 crates — parser red+green (the green/red parity corpus caught the green copy drifting on a fuzz input), booking, validate, implicit prices, dedup fingerprint (distinct hash tag), and five wire mirrors. WIT 3.2.0 → 3.3.0 (additive; the case only crosses on pre-booking egress — rustfava regen is a follow-up).

Tests

6-case integration suite (full issue table + {6 # 10} booking at 11.00/unit — the case the issue's example masked) · committed differential fixture (tests/compatibility/files/costs/, the corpus covered @@ prices but never # costs) · two legacy tests that pinned the old contract flipped with history documented.

⚠️ Breaking

Ledgers that balanced under the {a # b} ≡ {{b}} misreading were genuinely unbalanced per beancount and now error — compat-policy bucket 1; release-notes callout needed.

Verified: 104 workspace suites green · clippy -D warnings · rustdoc -D warnings · fmt · fixture VALID via rledger check.

Closes #1700

🤖 Generated with Claude Code

…N*a + b)

Beancount's compound_amount: {a # b} on N units costs N*a + b total,
booking per-unit (N*a + b)/N. The parser folded the form into {{b}}
(dropping a, on the explicit premise that the per-unit was redundant) and
mis-read {# b} as PerUnit{b} ({# lexes as a fused L_BRACE_HASH the old
detector missed). Consequences (issue #1700's table, all repro'd): valid
compound ledgers errored E3001, INVALID ones balanced silently, and lots
booked at the wrong cost basis whenever N*a + b != b (the issue's own
example masked this: 10/2 happened to equal the written 5.00).

Fix, per the agreed design: a fourth CostNumber variant carrying the form
as written (units may be interpolated, so the parser cannot combine).

- Parser (red + green — the green/red parity corpus caught the green copy
  drifting): compound detection for {a # b} / {# b} / {a #}, omitted
  sides zero.
- Booking: augmentation folds Compound into the existing
  Total->PerUnitFromTotal conversion with effective_total = N*a + b, so
  every post-booking consumer (STRICT matching, BQL, lot display, FFI
  egress of booked costs) is form-agnostic; both residual tiers (Decimal
  + BigDecimal) weigh N*a + b via the shared generic arm.
- Wire (additive): WIT rustledger:ledger 3.2.0 -> 3.3.0 compound case;
  ffi-component both directions; ffi-wasi input; plugin-types +
  plugin round-trip; wasm JSON. Dedup fingerprint hashes a distinct tag
  ({5 # 10} must not collide with {5}, {{10}}, or booked (5,10)).
- per_unit()/total() accessors return None for Compound (the effective
  values need units; exposing the lump as "total" would recreate the
  mis-weighing in consumers).

Two legacy tests pinned the old total-form contract — themselves partial
fixes that stopped the total from being dropped while baking in dropping
the per-unit; both flipped with the history documented. New coverage:
6-case integration suite (full issue table + asymmetric {6 # 10} booking
at 11.00/unit) and a committed differential fixture
(tests/compatibility/files/costs/) cross-checkable against bean-check —
the corpus covered @@ total prices but never # total costs.

BREAKING: ledgers that balanced under the {a # b} == {{b}} misreading
were genuinely unbalanced per beancount and will now error (compat-policy
bucket 1). Embedders: WIT 3.3.0 is additive; bindings regen picks up the
new case, which only crosses on pre-booking egress.

Closes #1700

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@robcohen
robcohen requested a review from a team as a code owner July 3, 2026 01:01
Copilot AI review requested due to automatic review settings July 3, 2026 01:01
@codecov

codecov Bot commented Jul 3, 2026 •

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Jul 3, 2026 •

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

Tool Time Memory
rustledger 41.3ms ± 1.9ms 23.8 MB
beancount 760.6ms ± 4.3ms 50.7 MB
Comparison 18.4x faster 2.1x less

vs main branch (2026-07-02)

Metric main this PR Change
Time 39.8ms 41.3ms +3.8% ✅
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

All 14 changed manifest entries are compound-form files (lima
CostBothCosts / CostMissingNumber* / GrammarExceptionsCompoundAmount /
TotalCost* cases, donearm accounts, and the new costs/ fixture) — no
unrelated parse output changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

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 fixes Beancount-compatible handling of compound cost syntax {a # b} by preserving both the per-unit and lump-total components through parsing and pre-booking representations, then booking it into the existing PerUnitFromTotal shape using the effective total N*a + b. This corrects balance weighting, prevents silent acceptance of invalid ledgers, and updates the workspace’s wire/FFI/plugin surfaces accordingly.

Changes:

  • Introduces CostNumber::Compound { per_unit, total } and parses {a # b}, {# b}, etc. as compound costs (including {# lexing as L_BRACE_HASH).
  • Updates booking/residual weighting/implicit-price extraction/formatting/fingerprinting to handle the new compound variant and apply Beancount semantics (N*a + b).
  • Adds compatibility fixtures + validate integration tests, and updates plugin + WASM + FFI (WASI + component/WIT) representations (WIT package 3.2.0 → 3.3.0).

Reviewed changes

Copilot reviewed 25 out of 27 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/compatibility/files/costs/compound_hash.beancount Adds a differential fixture covering #-cost forms and expected balancing/booking behavior.
tests/compatibility/files/.gitignore Ensures the new costs/ fixtures are kept in-tree and not ignored.
crates/rustledger-wasm/src/types.rs Extends WASM JSON types with a Compound cost-number variant.
crates/rustledger-wasm/src/convert.rs Serializes core CostNumber::Compound to WASM JSON output.
crates/rustledger-validate/tests/compound_cost_test.rs Adds integration tests pinning compound-cost weighting and booked-cost derivation.
crates/rustledger-validate/src/validators/transaction.rs Validates negative compound components with targeted diagnostics.
crates/rustledger-plugin/tests/fixtures/sample_stub/Cargo.lock Updates fixture lockfile to the newer rustledger-plugin-types version.
crates/rustledger-plugin/src/native/plugins/implicit_prices.rs Converts plugin wire Compound costs into core CostNumber::Compound for implicit price extraction.
crates/rustledger-plugin/src/native/plugins/currency_accounts.rs Accounts for Compound costs in weight computation for neutralization logic.
crates/rustledger-plugin/src/convert/to_wrapper.rs Converts core CostNumber::Compound into plugin wire CostNumberData::Compound.
crates/rustledger-plugin/src/convert/from_wrapper.rs Converts plugin wire CostNumberData::Compound back into core CostNumber::Compound.
crates/rustledger-plugin-types/src/lib.rs Adds Compound to the plugin wire enum and updates helper accessors to avoid misinterpretation.
crates/rustledger-parser/tests/parser_integration_test.rs Updates regression coverage to assert {N # T} parses as compound (not collapsed to Total).
crates/rustledger-parser/src/cst/green.rs Mirrors red CST conversion: detects HASH/L_BRACE_HASH and emits CostNumber::Compound.
crates/rustledger-parser/src/cst/convert.rs Implements compound detection returning both sides and updates unit tests accordingly.
crates/rustledger-ops/src/fingerprint.rs Adds a distinct fingerprint tag for compound cost numbers to avoid collisions.
crates/rustledger-importer/tests/fixtures/sample_stub/Cargo.lock Updates fixture lockfile to the newer rustledger-plugin-types version.
crates/rustledger-ffi-wasi/src/types/input.rs Extends WASI JSON-RPC input types to accept Compound costs and convert into core types.
crates/rustledger-ffi-component/wit/world.wit Bumps WIT package version and adds compound to the exported cost-number type.
crates/rustledger-ffi-component/src/convert.rs Maps core ↔ WIT cost numbers including the new compound variant.
crates/rustledger-core/src/shift_spans_impls.rs Ensures span shifting traverses both compound components.
crates/rustledger-core/src/implicit_prices.rs Computes effective per-unit price for compound costs (`a + b/
crates/rustledger-core/src/format/amount.rs Formats compound costs as {per_unit # total CCY}.
crates/rustledger-core/src/cost.rs Adds the Compound variant and integrates it into cost accessors, resolution, and display.
crates/rustledger-booking/src/lib.rs Corrects cost-weight ladder to include compound semantics (units*per_unit + sign*total).
crates/rustledger-booking/src/book.rs Converts compound costs into booked PerUnitFromTotal via effective total `

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

Comment thread crates/rustledger-booking/src/book.rs Resolved
@robcohen

robcohen commented Jul 3, 2026

Copy link
Copy Markdown
Member Author

Note on the advisory SemVer (plugin-types) red: it is correctly reporting the intentional API addition (CostNumberData::Compound). The enum is deliberately exhaustive — plugin authors get a compile error pointing at compound costs rather than silently mishandling them (the same mechanism that found all 12 in-repo consumer sites). Crate version bump rides the next release cut, as with prior wire-shape additions. The check is advisory by design (ci.yml: "deliberately NOT part of the build merge gate").

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

✅ Compatibility Test Results

Metric Result
Check Compatibility 100%
BQL Compatibility 100%
Full AST Match 100%
Total Files 793
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

robcohen and others added 3 commits July 3, 2026 01:15
TS / JSON Schema / Python bindings pick up the compound cost case
(additions only — 46 lines across the three generated surfaces).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two follow-ups the FFI parity corpus caught on the compound-cost change:

1. Reductions: a compound spec reached the lot matcher (and later
   apply()) unresolved — Compound deliberately exposes no effective
   per-unit, so matching failed and apply()'s booked-first invariant
   panicked. Compound now normalizes to PerUnitFromTotal (combined
   total N*a + b preserved exactly) ONCE at the top of the booking
   loop, so reductions, augmentations, and apply() all see resolved
   forms; the earlier augmentation-arm special case is reverted. The
   normalization deliberately does NOT default the spec date: on a
   reduction the date is a lot-match FILTER (injecting the sell date
   matches nothing — found by the sell-through-compound-lot repro);
   augmentation lot dates already default via resolve()'s date
   parameter, as for plain {N} specs.

2. CACHE_VERSION 12 -> 13: the binary cache is keyed on
   path+mtime+size, so caches written by pre-#1700 binaries would
   serve the old mis-parse (Total{b} / PerUnit{b}) to fixed binaries —
   observed live during verification. This is exactly the "CostNumber
   gains additional fields" case the v12 cache note calls out. The
   rkyv layout tripwires updated per their own procedure: existing
   byte fixtures still pin (variant appended, discriminants stable),
   a Compound fixture joins them, MetaValue guard bumps version only.

New regression test: buy/sell cycles matching compound-booked lots by
resolved per-unit and by compound spec as the filter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The version_matches_wit_package parity guard requires them to move
together; verified locally with the full wasip2 parity suite (20/20,
incl. the corpus parity test that caught the reduction gap).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@robcohen
robcohen merged commit 6778b69 into main Jul 3, 2026
57 of 59 checks passed
@robcohen
robcohen deleted the fix/compound-cost-weight branch July 3, 2026 03:22
@robcohen robcohen mentioned this pull request Jul 3, 2026
robcohen added a commit that referenced this pull request Jul 3, 2026
Minor (breaking) release carrying the route-smoke fix wave (#1699/#1700/
#1701, found by rustfava's new route-smoke suite):

- BREAKING parser/booking: compound cost {a # b} now carries both
  components and weighs N*a + b per beancount (#1700, via #1704).
  Ledgers that balanced under the old {a # b} == {{b}} misreading were
  genuinely unbalanced and now error; lot cost bases for compound specs
  are now correct. Binary caches invalidate automatically
  (CACHE_VERSION 13).
- BREAKING plugin-types: CostNumberData gained the Compound variant —
  exhaustive matches in plugin code need a new arm (deliberate: silent
  mishandling of compound costs is the failure mode the exhaustive enum
  prevents).
- FFI: WIT rustledger:ledger 3.2.0 -> 3.3.0 (additive compound case);
  runtime API_VERSION 3.3. Embedders regenerate bindings
  (rustfava#234); post-booking egress is unaffected.
- query: only() propagates NULL (fava Holdings by_currency 500, #1699
  via #1702); one-arg value() is inventory-typed for every row and the
  component declares column datatypes from the first non-NULL value
  (#1701, via #1703).

Workspace 0.19.1 -> 0.20.0; packages/mcp-server bumped to match.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
robcohen added a commit that referenced this pull request Jul 4, 2026
…s like red

fuzz_green_eq_red found (#1713) an input where the green-wired parse
and parse_red_only disagree on a compound cost: red produced
Compound { per_unit: 2, .. } while green produced per_unit: 0.

Root cause: #1704 gave red TWO number semantics — cost_compound_numbers
retries past unparsable NUMBER tokens on each side of the hash
(is_none() guards re-arm when the parse fails), while the plain path
latches the first NUMBER token via cs.number() — but green kept a
single latched tracker, satisfying only the plain path. On garbage
like `{<unparsable> 2 # ...}` red retried to 2; green latched
None -> 0.

Green now carries both: a retried pre-hash tracker for the compound
path plus the existing latched first-token tracker for the plain path
(and the post-hash side switches from latch to retry, matching red).

- Regression test pins the 418-byte fuzzer input
  (fuzz_regressions/diverge_compound_cost_unparsable_number.bin).
- CACHE_VERSION 13 -> 14 (+ changelog): parse output changes for
  inputs with garbage around compound costs; stale caches would
  resurrect the divergence. Tripwire fixture versions advance
  (layouts unchanged).
- Fuzz-validated: 5.34M executions / 9 min on the fixed converter,
  zero artifacts.

Closes #1713

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
robcohen added a commit that referenced this pull request Jul 4, 2026
The function was replaced by cost_compound_numbers in #1704; the module
doc and a parity-corpus comment still narrated the old latch semantics.
The corpus entry still passes because BOTH parsers now retry past the
unparsable post-hash number and agree on Compound{7, 0} — the comment
now says so. (Copilot review catch on #1714.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
robcohen added a commit that referenced this pull request Jul 4, 2026
…s like red (#1714)

* fix(parser): green compound-cost conversion retries unparsable numbers like red

fuzz_green_eq_red found (#1713) an input where the green-wired parse
and parse_red_only disagree on a compound cost: red produced
Compound { per_unit: 2, .. } while green produced per_unit: 0.

Root cause: #1704 gave red TWO number semantics — cost_compound_numbers
retries past unparsable NUMBER tokens on each side of the hash
(is_none() guards re-arm when the parse fails), while the plain path
latches the first NUMBER token via cs.number() — but green kept a
single latched tracker, satisfying only the plain path. On garbage
like `{<unparsable> 2 # ...}` red retried to 2; green latched
None -> 0.

Green now carries both: a retried pre-hash tracker for the compound
path plus the existing latched first-token tracker for the plain path
(and the post-hash side switches from latch to retry, matching red).

- Regression test pins the 418-byte fuzzer input
  (fuzz_regressions/diverge_compound_cost_unparsable_number.bin).
- CACHE_VERSION 13 -> 14 (+ changelog): parse output changes for
  inputs with garbage around compound costs; stale caches would
  resurrect the divergence. Tripwire fixture versions advance
  (layouts unchanged).
- Fuzz-validated: 5.34M executions / 9 min on the fixed converter,
  zero artifacts.

Closes #1713

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: regenerate sample_stub fixture lockfiles for 0.20.1

The v0.20.1 release bump did not regenerate these, so every cargo
invocation re-pins rustledger-plugin-types 0.20.0 -> 0.20.1 and dirties
the tree — which breaks prek pre-push stash handling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(parser): update stale cost_total_after_hash references

The function was replaced by cost_compound_numbers in #1704; the module
doc and a parity-corpus comment still narrated the old latch semantics.
The corpus entry still passes because BOTH parsers now retry past the
unparsable post-hash number and agree on Compound{7, 0} — the comment
now says so. (Copilot review catch on #1714.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
robcohen added a commit that referenced this pull request Jul 10, 2026
* fix: compound costs mispriced in doctor region and ops clamp (L2/L3)

Two consumers computed cost via the total()/per_unit() accessor chain,
which returns None for CostNumber::Compound — both silently miscost
compound (`{a # b}`) lots. Found by the Phase-0 duplication sweep
(cost-resolution family) and confirmed empirically:

- `doctor region --conversion cost` (L3, trivially reachable): reads
  the RAW load stream where Compound survives as-written; the accessor
  fallthrough displayed the bare unit count mislabeled with the cost
  currency — `10 WIDGET {5.00 # 10.00 USD}` showed "10 USD" instead of
  60.00 USD, and the net-changes view didn't balance (+10/-60).
- `ops::clamp` (L2, contract-gated): the hand-rolled per-unit ladder in
  posting_position had no Compound arm, so a pre-window compound lot
  was summarized COST-LESS — basis destroyed in the clamped opening
  balance. Masked on the standard load path (booking rewrites Compound
  first) but ops::clamp is a public API and the WIT builder input type
  explicitly allows Compound from hosts.

Fixes:
- core: new `CostNumber::total_for(units)` — exhaustive over all four
  variants (stored totals stay precision-exact; Compound = N·a + b per
  the #1704 rule). Doc directs total-cost consumers here instead of
  accessor chains.
- doctor region: accessor chain → total_for. Now shows 60.00 USD and
  balances.
- ops clamp: posting_position delegates to the canonical
  Position::from_posting (→ CostSpec::resolve, 4-variant-complete),
  with the transaction date for lot-date fill — matching how booking,
  query, and reports build lots.

Regression tests: core total_for (all variants), clamp compound-basis
preservation (summarized per-unit 6.00), doctor region integration
(60.00 USD asserted, unit-count-mislabel pinned against).

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

* fix: sign total_for by units; bare {CCY} cost specs fall through to units (review)

Two review catches on the original fix:

- total_for returned the unsigned stored total for Total /
  PerUnitFromTotal (and an unsigned lump in Compound) while PerUnit
  was signed through multiplication — a reduction costed with
  opposite signs depending on spec shape. All variants now take the
  sign from units, matching the balance-weight rule; new test pins
  negative- and zero-units behavior for every variant.

- doctor region's cost branch handled a numberless cost spec (`{}`,
  bare `{USD}` lot matching) by displaying the unit count labeled
  with the cost currency — the original mislabel bug in a different
  coat. Numberless specs now fall through to the units display.

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

* test: pin the compound total to the Broker line (deep review)

A bare "60.00 USD" match was satisfied by the cash leg and passed
even under the pre-fix bug; only the negative assertion actually
pinned the fix.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
robcohen added a commit that referenced this pull request Jul 10, 2026
…een and red walkers (#1737)

* refactor(parser): share cost-spec and meta-value semantics between green and red walkers

The green (GreenNodeData) and red (SyntaxNode) CST converters each
carried a hand-written copy of the two most drift-prone token
semantics, and every historical green/red fuzz divergence (#1704,
#1713, the {*} merge re-arm) landed in one of them:

- cost specs: red spread the rules over four sources (ast accessors
  is_total/is_merge/number/currency/date/label plus
  cost_compound_numbers), green re-fused them into one loop with
  explanatory comments about how to mirror red;
- meta values: red's meta_value_from_entry priority ladder plus
  meta_entry_has_minus_sign, green's meta_value re-collected the same
  first-of-kind latches.

Both walkers consume exactly the node's DIRECT child tokens (red's
first_token accessors are direct-child too), so the semantics are a
pure function of the (kind, text) token sequence. Introduce a
pub(super) TokenView trait (implemented by rowan::SyntaxToken and
&GreenTokenData) and move the two fused loops into shared functions
in convert.rs — cost_spec_from_tokens and meta_value_from_tokens —
that both walkers now call. The hard-won semantics notes (dual number
semantics with compound retry vs plain latch, fused L_BRACE_HASH
opener, positional merge flag, minus-before-number detection,
priority ladder with parse-failure fallthrough) are consolidated on
the shared functions.

The red walker's cost_compound_numbers and meta_entry_has_minus_sign
are deleted (their logic lives in the shared loop); the ast accessors
remain for other consumers (format.rs). The raw-token sibling
value_tokens_to_meta (adjacency-based pushmeta/custom semantics) is
unchanged.

Verified behavior-identical: full parser suite including the
parse_green_eq_red_corpus regressions and fuzz_regressions fixtures,
plus a one-off differential sweep parsing all 322 repo .beancount
fixtures through both paths and comparing directives/errors/comments/
options/occurrences — zero divergences. Loader suite green.

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

* docs: note inherent-method resolution in TokenView impl (review)

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
robcohen added a commit that referenced this pull request Aug 3, 2026
… it (#1942)

* fix(parser): evaluate arithmetic in a cost spec instead of truncating it

`{10.00 * 3 USD}` booked a cost of 10.00. The price path has always evaluated
expressions (`convert_amount_to_incomplete` -> `evaluate_amount_expression`),
while the cost path latched the first NUMBER token and dropped the rest - so a
single posting could carry a correctly evaluated price and a truncated cost.

  Assets:Something   2 HOOL {10.00 * 3 USD} @ 20.00 * 2 USD
    beancount  cost 30.00  price 40.00
    before     cost 10.00  price 40.00
    after      cost 30.00  price 40.00

Two consequences, the second worse. The cost basis was wrong, which propagates
into capital gains and every cost-denominated report. And because the weight
follows the cost, the transaction then failed to balance - a FALSE E3001
rejecting a file beancount loads with zero errors.

THE FIX IS TO SHARE THE EVALUATOR, NOT TO ADD A SECOND ONE

This is the drift shape CLAUDE.md's Canonical-Function Discipline describes:
two implementations of one computation with nothing asserting agreement. So
`parse_arith_expr` / `_term` / `_primary` are generalized from
`&[crate::SyntaxToken]` to `&[T] where T: TokenView` - the trait already
exposes exactly the `kind()` + `text()` the evaluator needs - and the cost path
now calls them. Both green and red conversion go through
`cost_spec_from_tokens`, so both are fixed by construction rather than by
mirroring.

A bare NUMBER still takes the existing latch: `cost_region_value` returns None
unless an operator or paren is present. That is deliberate. The latch carries
legacy sign handling and is what the current tests pin, so routing plain
numbers through the evaluator would be a behavior change smuggled in beside a
bug fix.

TWO THINGS THAT COST A DEBUG CYCLE EACH, BOTH NOW COMMENTED IN PLACE

Trivia. `cost_spec_from_tokens` is handed EVERY child token, whitespace
included, and the evaluator refuses tokens it does not recognize - so the first
version saw `10.00 WS * WS 3`, failed, and fell back to the latch. The bug was
still there, with more code in front of it. The other two evaluators filter
trivia for exactly this reason.

The CACHE. After the parser was provably fixed (a probe showed
`PerUnit { value: 30.00 }`), `rledger check` still reported the imbalance:
a stale cache was serving the old parser output. CACHE_VERSION 17 -> 18. The
archived LAYOUT is unchanged, which is precisely why the bump matters - nothing
would have rejected the old bytes, so users on a fixed build would have kept
the truncated cost basis until they cleared the cache by hand. The two
FIXTURE_VERSION tripwires in cache.rs asked the right question and are moved
with a note saying which of the two cases this is.

TESTS

- `cost_spec_arithmetic_is_evaluated` asserts the resolved cost number, not a
  substring of debug output: a bare `contains("30.00")` would be satisfied by
  the price, which is `20.00 * 2` and also renders `40.00`.
- `cost_spec_arithmetic_green_eq_red` runs six shapes - plain, parenthesized,
  with a date, compound `#`, and two MALFORMED ones - through both conversion
  paths and requires identical output. Every historical cost-spec divergence
  (#1704, #1713, the `{*}` merge flag) landed in a hand-mirrored copy of these
  semantics.
- Verified the new test FAILS with the fix disabled, so it is a guard rather
  than decoration.

Both `parser-lima` corpus fixtures now match beancount exactly (252.021 and
252.0210, previously 120.01 and 120.010).

Closes #1939.

* fix(parser): regenerate the 4 drifted baselines, and pin the sign fix

The corpus baseline drifted on FOUR files, not the two the arithmetic fix
predicted. The other two are TotalsAndSigns.CostNegative and
TotalCostNegative, and they turned out to be the same root cause wearing a
different hat.

  {-200.00 USD}      before  200.00   after  -200.00   beancount  -200.00
  {# -200.00 USD}    before   20.00   after   -20.00   beancount   -20.00

A leading MINUS was never part of the latched number, so a negative cost was
booked with its SIGN INVERTED. Routing it through the shared evaluator, which
handles unary minus, fixes it; both fixtures now agree with beancount exactly.

That contradicts the claim in the previous commit that plain numbers keep the
old latch and sign behavior is untouched. A signed number does now route
through the evaluator. It is a fix, not a regression - verified that both
tools rejected these two files before AND after (beancount InterpolationError,
rledger E3001), so only the recorded cost changed - but the earlier wording
was wrong and the PR description is corrected.

`negative_cost_keeps_its_sign` pins it separately from the arithmetic test:
"arithmetic is evaluated" and "a leading sign is part of the number" are
different claims, and a refactor could satisfy one while breaking the other.
Verified it fails with the fix disabled.

BASELINE REGENERATED SURGICALLY, NOT WHOLESALE. A blanket BASELINE_UPDATE=1
rewrote 22 lines. My local corpus is 734 files against the manifest's 715 -
three repository clones failed during fetch, and the checkout carries
committed fixtures the CI corpus does not - so it also added interpolation/*
entries and changed a donearm-ledger hash that this change cannot possibly
affect. Committing that would have smuggled unrelated baseline movement past
review. Only the 4 lines CI actually reported as drifted are updated.

* fix(wasm): bump the WASM cache too, and correct two review findings

Three review comments, one of which was a bug I missed.

THE WASM CACHE (the real one). `crates/rustledger-wasm/src/cache.rs` archives
`Vec<Directive>` - the same parsed directives whose cost numbers this PR
changes - behind its own CACHE_VERSION, which I did not touch. Its layout is
unchanged, so a v3 blob would be ACCEPTED and deserialized happily, serving a
truncated or sign-flipped cost basis on a build that has the fix. Exactly the
failure the loader bump exists to prevent, in a second cache. 3 -> 4.

I had already seen this file while grepping for CACHE_VERSION and did not
follow it up.

THE SIGN COMMENTS. Two comments claimed the latch "carries legacy sign
handling". It does not: the latch reads NUMBER tokens only and never applied a
leading sign, which is why `{-200.00 USD}` was booked POSITIVE. Both now say
what is actually true - an UNSIGNED bare number keeps the latch, a signed one
routes through the evaluator, and that is itself a fix.

THE DEBUG-STRING ASSERTIONS. Replaced with typed access. The arithmetic test
uses `CostNumber::per_unit()`; the sign test matches the enum structurally,
because `Compound` carries both halves and NEITHER accessor can express it
(both return None by design until units are known), so an accessor alone
cannot state that claim. Both still fail under sabotage - verified - so the
change did not soften them into decoration.

  cost_spec_arithmetic_is_evaluated --- FAILED
  negative_cost_keeps_its_sign --- FAILED
    a negative per-unit cost must keep its sign, got Some(PerUnit { value: 200.00 })
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.

booking: per-unit-from-total cost ({a # b}) weight is wrong in all three forms

2 participants