Visitar URL original
fix(booking): stop padding a balance on the pad's own date by robcohen · Pull Request #2187 · rustledger/rustledger · GitHub
Skip to content

fix(booking): stop padding a balance on the pad's own date - #2187

Merged
robcohen merged 6 commits into
mainfrom
fix/same-date-pad-balance
Aug 30, 2026
Merged

robcohen merged 6 commits into
mainfrom
fix/same-date-pad-balance

Conversation

@robcohen

@robcohen robcohen commented Aug 30, 2026 •

Copy link
Copy Markdown
Member

What

A pad and the balance it targets on the same date synthesized a padding
transaction beancount does not:

pad applied final Assets:A
beancount 3.x no — "Unused Pad entry" 40.00 USD
rustledger (before) yes, 60.00 USD 100.00 USD

check exited non-zero, but every read path — query, report balances —
returned the padded figure. And it reported the pad as unused in the same run
that booked it
.

Cause

beancount checks a balance at the start of its day: entry_sortkey gives
Balance -1 and Pad the default 0, so a same-date balance is evaluated
before the pad exists and consumes nothing.

Two places disagreed with that:

  • DirectivePriority had Pad = 2 before Balance = 3, with a comment
    asserting "padding before balance assertions" — the wrong belief, written
    down.
  • process_pads sorted by date alone, so same-date directives kept source
    order: a pad written above its balance was processed first and the balance
    consumed it.

Both fixed — Balance now precedes Pad, and process_pads sorts by the
canonical booking_sort_key.

Two tests pinned the old behavior

Both are inverted, with the evidence in their comments:

  • test_merge_with_padding_same_date_pad_balance_synth_comes_first asserted a
    same-date synth exists. The ordering guarantee it was really protecting —
    that a synth precedes its balance — is preserved in a new test on the
    day-earlier case, which is where a synth actually exists.
  • test_sort_directives_pad_before_balance, wrong down to its name.

Verification

  • The fixture returns 40.00, matching beancount. A pad one day earlier
    still pads to 100.00, also matching — so the fix is specific to the
    same-date case rather than disabling padding.
  • check still emits E2003 and E2001, which is exactly what bean-check
    emits for this file. The difference is that we no longer book the pad we call
    unused.
  • Booked values unchanged across all 23 pad-bearing corpus files, compared
    against a main-built binary — with a negative control confirming the
    comparison detects the issue's own fixture changing.
  • The validator and the booker now agree. On the reported file we emit
    E2003 and synthesize nothing; on the three padding cases we emit no E2003
    and do pad. All four match bean-check's judgement. That contradiction —
    calling a pad unused in the same run that booked it — was the issue's third
    complaint.
  • Blast radius confined to pad-bearing files: 59 pad-free files produce
    byte-identical #entries output.
  • The four new corpus_parity divergences are the prebuilt component still
    using the old order — native=(balance) component=(pad) — which is what a
    correct engine change looks like against a stale component.

Two things I want to be explicit about

The booked-value gate reports clean, and that is not evidence here. Its
axis is prices; it does not compare balances, so it cannot see this change. I
verified values by diffing per-account sums against a main binary instead.

Synth placement within a date is still not beancount's, and this PR moved it.
For a pad sharing a date with an unrelated balance, #entries gives
transaction balance pad where bean-query gives balance pad transaction;
before this PR it was pad transaction balance. All three produce the same
account total, so it is placement rather than arithmetic — filed as #2188,
along with the now-stale pad_insertion_index special case that favours a
same-date balance.

Same-date ordering is still not fully beancount's. beancount interleaves
pads and transactions by lineno (both sort at 0); we group by type. This PR
fixes Balance-before-Pad only. The interleaving is #2149, unchanged here.

Closes #2150

Copilot AI lite review requested due to automatic review settings August 30, 2026 03:25
@robcohen
robcohen requested a review from a team as a code owner August 30, 2026 03:25
robcohen added a commit that referenced this pull request Aug 30, 2026
Review find on #2187. Sweeping the pad scenarios against beancount turned
up a second broken case, worse than the reported one: a pad written after
a balance on the same date, serving a LATER balance.

    2024-06-15 balance Assets:A   0.00 USD
    2024-06-15 pad     Assets:A   Equity:O
    2024-07-01 balance Assets:A  50.00 USD

bean-check accepts that file. On main the account came back with NO
balance at all and `check` reported nothing -- silently empty rather than
silently wrong. The same-date balance consumed the pad, so the later
assertion had nothing left.

The fix already covers it, because Balance now precedes Pad and the pad
stays pending. This adds the test.

Both halves of the fix are confirmed by mutation: restoring
Pad-before-Balance fails two tests, restoring the date-only sort in
`process_pads` fails one.

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.

🟢 Approval recommended

The change aligns booking semantics with beancount for the reported edge case and includes targeted test updates, with only a minor documentation nit noted.

Pull request overview

This PR fixes a booking-order edge case where a pad and its targeted balance on the same date incorrectly synthesized a padding transaction (diverging from beancount) and caused read paths to expose a “phantom” padded balance.

Changes:

  • Reorders same-date directive priority so Balance precedes Pad, matching start-of-day balance semantics and beancount’s ordering.
  • Updates pad processing to sort by the canonical booking_sort_key (date + priority) instead of date-only, preventing same-date pad consumption.
  • Adjusts documentation and tests to reflect that same-date pad+balance synthesizes no padding transaction, while day-earlier pads still synthesize.
File summaries
File Description
crates/rustledger-core/src/directive.rs Changes directive priority to Balance before Pad and updates ordering tests accordingly.
crates/rustledger-booking/src/pad.rs Sorts pad processing by booking_sort_key, updates merge documentation, and updates/adds tests for same-date vs earlier-date pad behavior.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread crates/rustledger-booking/src/pad.rs Resolved
@github-actions

github-actions Bot commented Aug 30, 2026 •

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

Shape rustledger beancount Speedup RSS (rledger / beancount)
simple 47.3ms ± 2.0ms 830.5ms ± 4.9ms 17.5x 55.0 MB / 49.9 MB
investment 71.9ms ± 4.2ms 3030.8ms ± 79.4ms 42.1x 59.1 MB / 58.1 MB
fifo 71.9ms ± 5.0ms 15413.2ms ± 56.9ms 214.5x 61.1 MB / 57.8 MB

vs base, measured in this run

Shape base this PR Change RSS base → PR
simple 48.4ms ± 2.7ms 47.3ms ± 2.0ms -2.1% ✅ within noise 55.1 MB → 55.0 MB (-0.0%)
investment 71.4ms ± 3.2ms 71.9ms ± 4.2ms +0.8% ✅ within noise 59.0 MB → 59.1 MB (+0.1%)
fifo 69.4ms ± 3.7ms 71.9ms ± 5.0ms +3.5% ✅ within noise 61.1 MB → 61.1 MB (+0.1%)

A difference counts only when it clears the combined spread of the two measurements, per shape (2σ: simple 1.5ms, investment 2.4ms, fifo 2.8ms), and is at least 5%.
10,000 transactions per shape · 20 runs, 3 warmup · hyperfine

What do these numbers mean?
  • simple — Two-posting transfers, one currency. The historical workload — keep as the floor, not as the benchmark.
  • investment — Cost specs, per-unit prices and lot reductions. Exercises booking and inventory, which simple leaves at 0.3%.
  • fifo — Bare {} reductions against a FIFO account — how FIFO sells are normally written. The other four shapes reach the ordered-selection path twice in 2k transactions between them, so it was effectively unprofiled until it turned out to be quadratic (plan_ordered scans every lot per reduction: FIFO ledgers are quadratic (8.8s at 20k transactions) #2083).
  • vs base: the PR and the base-branch commit it is built on, built and measured in the same job
  • RSS: peak resident set size, a single sample — reported without a verdict because there is no spread to test it against
  • A shape with no beancount column is one bean-check reports errors on; it is timed for rledger only

robcohen added a commit that referenced this pull request Aug 30, 2026
Review find on #2187. The issue explicitly left the FFI path unverified,
noting that `expand_pads` runs its own date-only sort and could diverge
from the CLI either way. Nothing there covered the case: the existing
`SAME_DATE_LEDGER` shares a date between a pad and an UNRELATED
transaction, with the balance a day later.

It is correct -- `expand_pads` gets its pads from `process_pads`, which
this branch fixed, so the decision is shared rather than duplicated --
but that was unverified rather than untrue. Now asserted, and confirmed
to fail when Pad-before-Balance is restored.

The date-only sort at the top of `expand_pads` is left alone. It orders
the OUTPUT pairs, not the pad decision, and changing it would reorder
same-date directives on the FFI path only. Whether the two paths should
share an ordering rule is worth deciding on its own, not as a rider here.
@github-actions

Copy link
Copy Markdown
Contributor

✅ Compatibility Test Results

Metric Result
Check Compatibility 100%
BQL Compatibility 100% (raw 93%, 170 masked)
Full AST Match 100%
Total Files 811
What's tested
  • Check: Exit codes and error counts match bean-check
  • BQL: Query results match bean-query
    The headline is the EFFECTIVE rate, after pairs where beanquery
    itself is wrong are masked. raw is the same run with nothing
    masked. A widening gap means the registry grew, not that rledger
    changed.
  • Files: ~800 test files downloaded from 10+ beancount repositories

Full results available in workflow artifacts

@codecov

codecov Bot commented Aug 30, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

robcohen added a commit that referenced this pull request Aug 30, 2026
The parse-side pin added with the blank-line fix covers what
`ParsedLedgerPayload` holds. `LedgerPayload` holds something else: the
output of the shared `process()` pipeline, sorted by
`canonical_sort_key` and booked.

That gap is not hypothetical. #2187 swaps `DirectivePriority` for `Pad`
and `Balance`, which reorders a cached ledger without changing a single
parsed directive -- the parse-side digest does not move, and a v16 blob
would deserialize into an order that build no longer produces.

The fixture puts a pad and a balance on the same date on purpose: that
pair is the one whose relative order the priorities decide, and on
different dates the date component of the sort key settles it and a
priority swap changes nothing. Verified by applying #2187's swap, which
fails this test and passes the parse-side one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012nNGPA44Dt32NyxWem26xr
robcohen added a commit that referenced this pull request Aug 30, 2026
Review find on #2187. Sweeping the pad scenarios against beancount turned
up a second broken case, worse than the reported one: a pad written after
a balance on the same date, serving a LATER balance.

    2024-06-15 balance Assets:A   0.00 USD
    2024-06-15 pad     Assets:A   Equity:O
    2024-07-01 balance Assets:A  50.00 USD

bean-check accepts that file. On main the account came back with NO
balance at all and `check` reported nothing -- silently empty rather than
silently wrong. The same-date balance consumed the pad, so the later
assertion had nothing left.

The fix already covers it, because Balance now precedes Pad and the pad
stays pending. This adds the test.

Both halves of the fix are confirmed by mutation: restoring
Pad-before-Balance fails two tests, restoring the date-only sort in
`process_pads` fails one.
robcohen added a commit that referenced this pull request Aug 30, 2026
Review find on #2187. The issue explicitly left the FFI path unverified,
noting that `expand_pads` runs its own date-only sort and could diverge
from the CLI either way. Nothing there covered the case: the existing
`SAME_DATE_LEDGER` shares a date between a pad and an UNRELATED
transaction, with the balance a day later.

It is correct -- `expand_pads` gets its pads from `process_pads`, which
this branch fixed, so the decision is shared rather than duplicated --
but that was unverified rather than untrue. Now asserted, and confirmed
to fail when Pad-before-Balance is restored.

The date-only sort at the top of `expand_pads` is left alone. It orders
the OUTPUT pairs, not the pad decision, and changing it would reorder
same-date directives on the FFI path only. Whether the two paths should
share an ordering rule is worth deciding on its own, not as a rider here.
@robcohen
robcohen force-pushed the fix/same-date-pad-balance branch from 81c3f90 to ac02fb0 Compare August 30, 2026 04:22
@robcohen

Copy link
Copy Markdown
Member Author

Rebased on main and added a wasm CACHE_VERSION bump (15 → 16) this PR was missing.

LedgerPayload archives the output of the shared process() pipeline, which sorts by canonical_sort_key — and that key reads DirectivePriority. Swapping Pad and Balance changes the list this cache holds without changing a single directive in it: same bytes per directive, different order, and the pad amounts that went with the old order. A v15 blob read by a fixed build serves exactly the arrangement this PR removes.

Nothing in the suite catches that class today — the version pin only forces a question, and it is answerable "loader-internal" when it is not. #2189 adds processed_ledger_archived_form_is_pinned, which fails on this exact swap; that is how the gap was found.

robcohen added a commit that referenced this pull request Aug 30, 2026
* fix(parser): a blank line no longer eats a directive's tags

`convert_note` and `convert_document` collected the `#tag` / `^link`
tokens on the header line by walking the node's direct tokens and
breaking at the first NEWLINE. A directive node begins with the trivia
that precedes it, so when a blank line sits above the directive its
first token is a NEWLINE and the loop broke before reaching anything --
every tag and link dropped.

That is the common case, not a corner: a blank line between directives
is how ledgers are normally written. Of the three tagged `document`
directives in the compatibility corpus, two lost their tags and one
kept them, the one written directly under another directive.

`document` has had this since long before `note` had any tags to lose,
and nothing caught it because every fixture placed the directive
immediately after another one. The completion test added here made the
same mistake first and passed while the bug was live; its fixture now
has the blank line that makes it real.

Both directives now share `header_tags_and_links`, which starts
collecting only once the header's STRING has been seen and breaks on
the newline after it.

Cache versions move because parser OUTPUT changed: loader 31 -> 32, and
wasm 15 -> 16. The wasm bump is overdue -- #2160 moved the loader
version and the pin and left this one at 15, so v15 is now ambiguous
between the old and new `ArchivedNote` shapes and every v15 blob has to
be rejected.

That miss is why `directive_archived_form_is_pinned` exists. The
version pin can only force a question; it cannot tell a loader-internal
change from one that moves our archived directives, and on #2160 the
question got the wrong answer with every test still passing. The new
test hashes the archived bytes of one of every directive kind, so a
layout change and an output change both fail it. Verified against both:
reverting this parser fix and swapping `Note`'s two field declarations
each move the digest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012nNGPA44Dt32NyxWem26xr

* test(wasm): pin the processed ledger the cache actually archives

The parse-side pin added with the blank-line fix covers what
`ParsedLedgerPayload` holds. `LedgerPayload` holds something else: the
output of the shared `process()` pipeline, sorted by
`canonical_sort_key` and booked.

That gap is not hypothetical. #2187 swaps `DirectivePriority` for `Pad`
and `Balance`, which reorders a cached ledger without changing a single
parsed directive -- the parse-side digest does not move, and a v16 blob
would deserialize into an order that build no longer produces.

The fixture puts a pad and a balance on the same date on purpose: that
pair is the one whose relative order the priorities decide, and on
different dates the date component of the sort key settles it and a
priority swap changes nothing. Verified by applying #2187's swap, which
fails this test and passes the parse-side one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012nNGPA44Dt32NyxWem26xr

* docs(parser): correct the rule the tag-collection comments state

Both call sites still described stopping "at the first NEWLINE" as the
thing that captures the header, which is the bug the commit before this
one removed. They now point at `header_tags_and_links`, which is where
the rule lives.

Also records that the formatter already guarded its header walks on a
`seen_content` flag for exactly this reason. The converter was the
outlier, not the pioneer.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
A `pad` and the `balance` it targets on the same date synthesized a
padding transaction beancount does not. On the issue's fixture the
account ended 60.00 USD richer -- 100.00 where beancount reports 40.00 --
and `check` reported the pad as UNUSED in the same run that booked it.

beancount checks a balance at the START of its day: `entry_sortkey` gives
Balance -1 and Pad the default 0, so a same-date balance is evaluated
before the pad exists and consumes nothing. Ours had `DirectivePriority`
Pad=2 before Balance=3, with a comment asserting "padding before balance
assertions", and `process_pads` sorted by DATE ALONE -- so a pad written
above its balance was processed first and the balance consumed it.

Both are fixed: Balance now precedes Pad, and `process_pads` sorts by the
canonical `booking_sort_key` rather than by date.

Two tests pinned the old behavior and are inverted, with the evidence in
their comments. One asserted a same-date synth exists and precedes the
balance; the ordering guarantee it was really protecting is preserved in
a new test on the day-earlier case, which is where a synth actually
exists. The other was named `test_sort_directives_pad_before_balance`.

Verified:
  - the fixture now returns 40.00, matching beancount; a pad one day
    earlier still pads to 100.00, also matching
  - `check` still emits E2003 and E2001, which is what bean-check emits
    for the same file -- the difference is that we no longer book the pad
    we call unused
  - booked values UNCHANGED on all four corpus files that contain a
    same-date pad+balance, compared against a main-built binary
  - directive order changes only in files containing pads: 59 pad-free
    files produce byte-identical `#entries` output
  - the four new `corpus_parity` divergences are the prebuilt component
    still using the old order (`native=(balance) component=(pad)`), which
    is what a correct engine change looks like against a stale component

Not fixed here, and worth stating: beancount interleaves same-date pads
and transactions by lineno (both sort at 0) while we still group by type.
That is the ordering divergence in #2149, unchanged by this.

The booked-value gate reports clean, but its axis is prices; it does not
compare balances and so does not exercise this.

Closes #2150
Review find on #2187. Sweeping the pad scenarios against beancount turned
up a second broken case, worse than the reported one: a pad written after
a balance on the same date, serving a LATER balance.

    2024-06-15 balance Assets:A   0.00 USD
    2024-06-15 pad     Assets:A   Equity:O
    2024-07-01 balance Assets:A  50.00 USD

bean-check accepts that file. On main the account came back with NO
balance at all and `check` reported nothing -- silently empty rather than
silently wrong. The same-date balance consumed the pad, so the later
assertion had nothing left.

The fix already covers it, because Balance now precedes Pad and the pad
stays pending. This adds the test.

Both halves of the fix are confirmed by mutation: restoring
Pad-before-Balance fails two tests, restoring the date-only sort in
`process_pads` fails one.
Review find on #2187. The issue explicitly left the FFI path unverified,
noting that `expand_pads` runs its own date-only sort and could diverge
from the CLI either way. Nothing there covered the case: the existing
`SAME_DATE_LEDGER` shares a date between a pad and an UNRELATED
transaction, with the balance a day later.

It is correct -- `expand_pads` gets its pads from `process_pads`, which
this branch fixed, so the decision is shared rather than duplicated --
but that was unverified rather than untrue. Now asserted, and confirmed
to fail when Pad-before-Balance is restored.

The date-only sort at the top of `expand_pads` is left alone. It orders
the OUTPUT pairs, not the pad decision, and changing it would reorder
same-date directives on the FFI path only. Whether the two paths should
share an ordering rule is worth deciding on its own, not as a rider here.
`LedgerPayload` archives the output of the shared `process()` pipeline,
which sorts by `canonical_sort_key` -- and that key reads
`DirectivePriority`. Swapping `Pad` and `Balance` therefore changes the
LIST this cache holds without changing a single directive in it: every
one archives to the same bytes, in a different order.

What a stale blob costs is narrower than it first looks, and the first
draft of this message got it wrong by saying the pad AMOUNTS come back
stale. They do not: `process_pads` re-sorts its input by
`booking_sort_key`, the same `(date, priority)`, so amounts computed
from a v16 blob still come out right. What differs is the order of the
directives the cache hands back -- what journal output, `SELECT` row
order and the FFI directive stream all read. A cache that returns a
different sequence than a fresh run is stale by definition, which is
reason enough and is the reason recorded.

`processed_ledger_archived_form_is_pinned`, which landed on main with
#2189 for exactly this class, failed on the rebase and had to be
re-pinned. That is the guard working: the version bump was argued for in
review before, and is now also demanded by a test.

Also folds in two review corrections: `pad.rs` no longer claims to sort
"by date" directly above the comment explaining that it sorts by the
booking key, and the variant census in `cache.rs` identifies variants by
`Directive::type_name` rather than `Debug` on `mem::Discriminant`, whose
format is not a documented guarantee -- it also names the missing
variant when it fails.
@robcohen
robcohen force-pushed the fix/same-date-pad-balance branch from ac02fb0 to d533cd4 Compare August 30, 2026 06:41
@robcohen

Copy link
Copy Markdown
Member Author

Correction to the verification notes on this PR.

I reported corpus_parity divergences as "20, unchanged from main (pre-existing)". That measurement was wrong. The test loads a prebuilt target/wasm32-wasip2/debug/rustledger_ffi_component.wasm, and no test command rebuilds it — the copy on my disk was three weeks old, so it was comparing today's native code against a stale component.

After cargo build -p rustledger-ffi-component --target wasm32-wasip2:

cargo test -p rustledger-ffi-component-tests   ->  ok, 0 divergences
cargo test --workspace                          ->  green

So there is no known-failing test here at all; the actual state is better than I described. The tell I missed was the number's stability — it stayed 20 because the component never changed, not because the failure was inherent. Every other verification figure I quoted (clippy, fmt, typos, the 693-file format scripts, the baselines) was measured directly and stands.

@robcohen

Copy link
Copy Markdown
Member Author

Fixed in the rebase (d533cd4). You were right that the comment contradicted the code — it said "Sort directives by date for processing" directly above the block explaining that date alone was the bug. I removed the stale line rather than reword it; the comment underneath already states the rule and why it changed.

Also folded into that commit: the cache.rs variant census now identifies variants by Directive::type_name instead of Debug on mem::Discriminant, whose format is not a documented guarantee.

@robcohen
robcohen merged commit b074bad into main Aug 30, 2026
71 of 72 checks passed
@robcohen
robcohen deleted the fix/same-date-pad-balance branch August 30, 2026 08:31
robcohen added a commit that referenced this pull request Aug 30, 2026
Four PRs shipped user-visible behavior changes without an entry: the
two formatter tag losses (#2192), the blank-line tag drop (#2189), the
same-date pad (#2187), and this branch's tolerance grammar (#2195).

The changelog already carries the direct sibling of the first one --
"`rledger format` no longer deletes a posting's currency, cost or price
when it has no units number" (#2142) -- so the omission was mine, not a
question of whether this class belongs here.

Each entry records the cache versions that moved with it, which is the
part a reader upgrading actually needs and the part not recoverable from
the diff.
robcohen added a commit that referenced this pull request Aug 30, 2026
* fix(parser): a balance tolerance may follow an arithmetic amount

`directive_arithmetic_value` scanned to the first top-level CURRENCY and
treated everything before it as the amount expression. With no currency
between the amount and the tolerance, that swallowed the tolerance:
`0.25 + 0.75 ~ 0.01 USD` was handed to the expression parser as
`0.25 + 0.75 ~ 0.01`, which is not arithmetic, so the whole value was
reported malformed.

Only the currency-less arithmetic form reached that state. With a
currency after the expression the scan stopped there and parsed, which
is why `100.00 ~ 0.01 USD` and `0.25 + 0.75 USD ~ 0.01 USD` both worked
and `0.25 + 0.75 ~ 0.01 USD` did not. `~` now ends the expression, which
is what it means; the tolerance is read separately as it always was.

This is what made `rledger format` emit a balance that no longer parses
(#2191): the formatter emits the single-trailing-currency form, which
is the beancount form and the one the parser already accepted for a
plain number.

Tested for VALUES, not just for parsing. An earlier bug in this same
scan fell back to "take the first NUMBER" and turned `(1 + 5) / 2.1`
into a silent assertion against 1 -- parsed cleanly, asserted the wrong
thing -- so each of the seven accepted forms is checked against the
amount it should evaluate to, with a ledger holding 0.25 confirming that
`0.25 + 0.75` does not match it.

Brings in the matrix-wide format-fidelity harness that #2192 could not
land: it fails on `balance_with_arithmetic_and_tolerance` without this
fix, which is how the bug was found.

Closes #2191

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012nNGPA44Dt32NyxWem26xr

* fix(parser): a price rejects a tolerance instead of discarding it

Review of the commit before this one caught a regression it introduced.
`directive_arithmetic_value` is shared by `balance` and `price`, so
making `~` end the amount expression applied to both -- and a price has
no tolerance. `price USD 1.10 + 0.05 ~ 0.01 EUR` went from a
malformed-value error to silently evaluating 1.15 and dropping the
tolerance on the floor. Trading a diagnosed error for a silent
half-reading of the author's input is a bad trade.

`ToleranceForm` now says which grammar is being parsed: `balance` stops
at `~`, `price` does not.

That restores the old handling of the arithmetic form and leaves the
plain one, `price USD 1.10 ~ 0.01 EUR`, which main ALREADY accepted the
same silent way -- `~` is not an arithmetic operator, so the scan took
the fast path and fell back to the first NUMBER, yielding 1.10 with the
rest discarded. Fixing only the half I broke would leave the identical
silent truncation one keystroke away, so a top-level `~` on a price is
now diagnosed outright.

Both forms are covered, and so are the three tolerance-free price forms
with the values they must evaluate to -- an error path that also stopped
accepting valid input would pass a test that only asserted rejection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012nNGPA44Dt32NyxWem26xr

* test(format): raise the round-trip floor to what actually round-trips

The fidelity harness guards against going quiet by asserting a minimum
number of fixtures reached the comparison. `> 20` was a third of the way
down from the real figure: 37 of the 42 fixtures round-trip today, the
other 5 being error fixtures, so ten could start failing to parse and be
skipped without the floor noticing. Skipping is exactly how this test
would go quiet.

Measured rather than guessed, and set just under the measured value so
adding fixtures does not churn it.

* test(parser): census which directives keep tags and which refuse them

The failure this session kept turning up is a third state between the
two: accept the tag, then drop it because the model has nowhere to put
it. `note` was in it (#2160), both formatters were in it (#2184), and a
price's tolerance was in it.

So this counts the directives rather than testing one. Seven refuse,
three keep, and each refusal is checked against its tag-free form too --
a rejection that also rejected valid input would satisfy an
assertion that only looked for an error.

`query` is in neither set: it accepts a tag and discards it. That is
asserted here as CURRENT behavior, not as correct behavior. Which way it
should go needs `bean-check` (beancount's `Query` has no tags field,
which argues for refusing; a parser-lima corpus file uses the form,
which argues it appears in the wild) and the container is not built
here. Filed as #2194. Written as an assertion so the day it changes this
fails and the census gets updated rather than quietly going stale.

I implemented the refusal and backed it out: it makes
`parser-lima/examples_data_full.beancount` error, and an unverifiable
strictness increase does not belong in a PR about tolerance parsing.

* chore(cache): bump the versions the tolerance changes invalidate

Loader 32 -> 33, wasm 17 -> 18, pin 33.

Parser OUTPUT moves in both directions here, which is why both caches
have to: a v32/v17 blob holds a parse error where this build now
produces a balance directive, and holds a silently truncated 1.10 where
this build now produces an error. Either way the cache would serve a
reading the binary no longer agrees with.

Both `CostNumber` and `MetaValue` fixture tripwires move version only --
balance and price amounts are `Decimal`s on the directive and take part
in neither archived layout, and the assertions after each comment prove
that rather than take the comment's word for it.

* fix(parser): a child node no longer stops the tilde scan

Review feedback on #2192 was that `emit_header_tags_and_links` bailed
out of its walk at the first child NODE, so it did not scan the whole
header. I fixed that there and then wrote `has_top_level_tilde` with the
identical `break` a few hours later.

Harmless today for the same reason it was harmless there -- a price's
only child node is a META_ENTRY, whose tokens are not direct children,
so nothing here can see a `~` written inside metadata. But a header that
ever gains a child node would stop being scanned at all, and the whole
point of this function is to refuse a tolerance rather than skip past
one.

Also records a blind spot in the format-fidelity test, found while
checking whether the tolerance clause is validated at all (it is not,
#2193): a model comparison cannot see data the model never held. `balance
X 1.00 USD ~ 0.01 EUR` formats to `1.00 ~ 0.01 USD`, erasing the EUR
from the file, and the test passes -- both sides lost it in the parser.
Which is exactly why the note-tag test also asserts on output text.

* test(parser): match the price-tolerance error on its kind, not its Debug

Review feedback: the assertion stringified the whole `ParseError` with
`{e:?}` and searched that. Debug output carries the span and context
fields too, so the test was coupled to formatting it does not care
about.

It now matches `ParseErrorKind::SyntaxError(m)` and searches `m`, which
is the part the test is actually about. Still pins the wording --
rewording the message fails it -- so this narrows what the assertion is
brittle to rather than weakening it.

* docs: record this run's fixes in the changelog

Four PRs shipped user-visible behavior changes without an entry: the
two formatter tag losses (#2192), the blank-line tag drop (#2189), the
same-date pad (#2187), and this branch's tolerance grammar (#2195).

The changelog already carries the direct sibling of the first one --
"`rledger format` no longer deletes a posting's currency, cost or price
when it has no units number" (#2142) -- so the omission was mine, not a
question of whether this class belongs here.

Each entry records the cache versions that moved with it, which is the
part a reader upgrading actually needs and the part not recoverable from
the diff.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

Same-date pad and balance inserts a phantom padding transaction that its own validator calls unused

2 participants