Visitar URL original
fix: pick price rates by date across both pair directions by robcohen · Pull Request #1760 · rustledger/rustledger · GitHub
Skip to content

fix: pick price rates by date across both pair directions - #1760

Merged
robcohen merged 1 commit into
mainfrom
fix/price-map-bidirectional
Jul 11, 2026
Merged

robcohen merged 1 commit into
mainfrom
fix/price-map-bidirectional

Conversation

@robcohen

Copy link
Copy Markdown
Member

What

convert()/value() in BQL preferred any direct (base, quote) price entry over the inverse direction, so an older direct rate silently shadowed a newer rate declared the other way round (-18.68 GBP @ 1.2655 USD). On the issue's ledger, four of five convert(sum(position), 'GBP') rows diverged from bean-query; all five now match exactly.

Why a build-time port, not a lookup-time fix

Python beancount's build_price_map materializes both directions of every price and picks purely by date recency. Its tie-breaks are emergent from the build algorithm and unanswerable per-lookup:

  • which direction survives a merge depends on global entry counts per pair (the direction with fewer rates is inverted and swallowed into the other);
  • which same-date rate wins depends on list order after that swallow (sorted_uniquify(..., last=True)).

Simulating that at lookup time is exactly the re-derivation anti-pattern from the Phase-3 canonical-function discipline. So PriceDatabase now holds two structures: the raw provenance store (unchanged — #prices table, len(), the #1006 dedup gating all read it) and a conversion index built by a line-by-line port of build_price_map's merge, auditable by diffing against the Python source. The transaction-derived extension prices (#567/#593) feed the same merge, making them behave exactly as if implicit_prices had materialized them.

Bonus: the chained lookup's four per-leg inverse branches collapse to direct legs (every pair is bidirectional by construction), and intermediates are tried in sorted order, making multi-path chains deterministic.

Spec update

PriceDB.tla modeled the old directed-independent semantics, so the model itself needed the fix: SetPrice now clears the opposite direction (one rate timeline per pair), a OneDirectionPerPair invariant pins it, the behavior corpus is regenerated (330 behaviors, TLC clean), and the replay asserts both the held rate and its derived reciprocal at 20 dp — the build may invert a swallowed rate twice, exactly like Python, leaving last-digit artifacts on both sides.

Testing

  • 7 new unit regressions: newer-inverse-wins (the issue shape), newer-direct-wins, swallow-smaller-into-larger, same-date last-wins tie-break, zero-rate never inverted, end-to-end from_directives with the issue's Format C/D transactions (asserts the 79.02 GBP conversion), chained-leg recency.
  • Full rustledger-query suite: 12 suites green (TLA replay + proptests updated to honor the documented sort_prices() contract — they previously read the raw store incidentally).
  • Issue ledger verified against the reported bean-query output: all five rows match.
  • clippy -D warnings, fmt, rustdoc -D warnings clean.

Closes #1759

🤖 Generated with Claude Code

@robcohen
robcohen requested a review from a team as a code owner July 11, 2026 13:48
Copilot AI review requested due to automatic review settings July 11, 2026 13:48
@codecov

codecov Bot commented Jul 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.07834% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/rustledger-query/src/price.rs 99.07% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented Jul 11, 2026 •

Copy link
Copy Markdown
Contributor

📊 Benchmark Results

Tool Time Memory
rustledger 26.6ms ± 0.4ms 23.8 MB
beancount 584.6ms ± 3.8ms 50.5 MB
Comparison 22x faster 2.1x less

vs main branch (2026-07-11)

Metric main this PR Change
Time 33.1ms 26.6ms -19.6% 🟢
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

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 BQL convert()/value() price selection so the most recent applicable rate is chosen by date recency across both pair directions, matching Python beancount’s build_price_map semantics (issue #1759). It introduces a dedicated conversion index in PriceDatabase built from raw price observations (including transaction-derived implicit prices) so that inverse-declared newer rates can supersede older direct rates deterministically.

Changes:

  • Refactors PriceDatabase to keep a raw provenance store and build a direction-blind lookup index via a port of beancount’s build_price_map merge/swallow/tie-break logic.
  • Updates TLA spec/config and behavior-derivation tooling to model “one timeline per pair” with inverse clearing and corresponding invariants.
  • Extends unit/property tests and TLA behavior replay to assert bidirectional consistency (including reciprocal rates) and ensure lookups go through the rebuilt index.

Reviewed changes

Copilot reviewed 6 out of 7 changed files in this pull request and generated no comments.

Show a summary per file
File Description
spec/tla/PriceDB.tla Updates the formal model so setting a rate clears the opposite direction and adds a pair-direction invariant.
spec/tla/PriceDB.cfg Enables the new OneDirectionPerPair invariant in TLC runs.
scripts/tla-behaviors.py Adjusts behavior derivation to account for inverse clearing and identify the nonzero “authoritative” write.
crates/rustledger-query/tests/tla_proptest.rs Finalizes the DB via sort_prices() so tests exercise the new lookup index.
crates/rustledger-query/tests/tla_behavior_replay.rs Rebuilds before reads and asserts reciprocal behavior with bounded precision.
crates/rustledger-query/src/price.rs Implements raw-sequence tracking + beancount-like merge into a bidirectional conversion index; updates lookups/tests accordingly.

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

@github-actions

Copy link
Copy Markdown
Contributor

✅ Compatibility Test Results

Metric Result
Check Compatibility 100%
BQL Compatibility 100%
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
  • Files: ~800 test files downloaded from 10+ beancount repositories

Full results available in workflow artifacts

BQL convert()/value() preferred any direct (base, quote) entry over
the inverse direction, so an OLDER direct rate silently shadowed a
NEWER rate declared the other way round (-18.68 GBP @ 1.2655 USD).
Python beancount's build_price_map materializes both directions of
every price and picks purely by date recency; its tie-breaks (which
direction survives a merge, which same-date rate wins) are emergent
from the build algorithm and cannot be reproduced by per-lookup
rules, so this ports the algorithm instead of simulating it:

- PriceDatabase now holds two structures: the raw provenance store
  (unchanged — feeds the #prices table, len(), and the #1006 dedup
  gating) and a conversion index built by a line-by-line port of
  build_price_map's merge: group per pair in ledger order, swallow
  the direction with fewer rates into the other (inverting, zero
  rates dropped), stable date sort + one rate per date keeping the
  last, then materialize all inverses.
- get_price/get_latest_price read the index; the chained lookup's
  four per-leg inverse branches collapse to direct legs (every pair
  is bidirectional by construction) and intermediates are tried in
  sorted order so multi-path results are deterministic.
- PriceDB.tla modeled the old directed-independent semantics; the
  spec now clears the opposite direction on every SetPrice (one rate
  timeline per pair), with a OneDirectionPerPair invariant, a
  regenerated behavior corpus, and the replay asserting both the
  held rate and its derived reciprocal (at 20 dp — the build may
  invert a swallowed rate twice, exactly like Python, leaving
  last-digit artifacts).

The issue's ledger now matches bean-query on all five rows
(rustledger previously diverged on four).

Closes #1759

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@robcohen
robcohen force-pushed the fix/price-map-bidirectional branch from dda8569 to d0a2741 Compare July 11, 2026 14:33
@robcohen
robcohen merged commit 7f48052 into main Jul 11, 2026
52 checks passed
@robcohen
robcohen deleted the fix/price-map-bidirectional branch July 11, 2026 15:15
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 convert() fails to invert implicit prices from transaction postings

2 participants