Skip to content

fix(amount): align IssuedCurrencyAmount Ord with 3-field Eq - #354

Open
e-desouza wants to merge 4 commits into
mainfrom
fix/issue-348-issued-currency-ord-eq
Open

fix(amount): align IssuedCurrencyAmount Ord with 3-field Eq#354
e-desouza wants to merge 4 commits into
mainfrom
fix/issue-348-issued-currency-ord-eq

Conversation

@e-desouza

Copy link
Copy Markdown
Collaborator

Why

IssuedCurrencyAmount derived PartialEq/Eq on all three fields (currency, issuer, value), but its hand-written Ord::cmp only compared value using lexicographic string ordering. This violates the Rust invariant a.cmp(b) == Equal ↔ a == b:

  • Two amounts with the same value but different currency or issuer (e.g. 100 USD vs 100 EUR) compared as Ordering::Equal by Ord while PartialEq::eq returned false. Any BTreeMap<IssuedCurrencyAmount, _> in calling code would silently collide these as the same key.
  • Lexicographic string ordering gave wrong numeric results: "10" < "9", so sorted collections had incorrect financial ordering.

Closes #348.

What changed

  • src/models/amount/issued_currency_amount.rs: replaced Ord::cmp body with numeric BigDecimal value comparison (already a dependency) followed by currency then issuer tiebreaks — making Ord consistent with derived Eq on all three fields.
  • Added five regression tests covering: Ord/Eq invariant for equal values, different currency same value, different issuer same value, numeric ordering (10 > 9), and PartialOrd consistency.

How to validate

cargo test --release --lib models::amount::issued_currency_amount::tests
# Expected: 5 passed, 0 failed

Property invariant verified by the new tests:

  • a == b → a.cmp(&b) == Ordering::Equal
  • a != b → a.cmp(&b) != Ordering::Equal ✓ (for differing currency or issuer with same value)
  • "10".parse > "9".parse numerically ✓

Comment thread src/models/amount/issued_currency_amount.rs Outdated
@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.53086% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.05%. Comparing base (2015c85) to head (8f8b8fe).

Files with missing lines Patch % Lines
src/models/amount/issued_currency_amount.rs 97.53% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #354      +/-   ##
==========================================
+ Coverage   87.01%   87.05%   +0.04%     
==========================================
  Files         255      255              
  Lines       33668    33748      +80     
==========================================
+ Hits        29295    29379      +84     
+ Misses       4373     4369       -4     
Flag Coverage Δ
integration 71.90% <ø> (ø)
unit 87.64% <97.53%> (+0.04%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/models/amount/issued_currency_amount.rs 98.00% <97.53%> (+28.00%) ⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown

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 IssuedCurrencyAmount ordering so it no longer compares amounts purely by lexicographic value string, which previously broke numeric ordering and could cause key collisions in ordered collections. It updates Ord::cmp to use numeric comparison and adds targeted regression tests for the Ord/Eq invariant and numeric sorting.

Changes:

  • Updated IssuedCurrencyAmount::cmp to compare value numerically and use currency/issuer as tie-breakers.
  • Added regression tests covering Ord/Eq consistency and numeric ordering behavior.

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

Comment on lines +50 to +54
let sv = BigDecimal::from_str(&self.value).unwrap_or_default();
let ov = BigDecimal::from_str(&other.value).unwrap_or_default();
sv.cmp(&ov)
.then_with(|| self.currency.cmp(&other.currency))
.then_with(|| self.issuer.cmp(&other.issuer))

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 0630bc85: removed #[derive(PartialEq, Eq)] and implemented both traits manually. PartialEq::eq now uses BigDecimal numeric comparison when both values parse successfully (falling back to string equality otherwise), matching Ord::cmp semantics. As a result, "100" and "100.0" are now equal by both Eq and Ord, satisfying the a == b ↔ a.cmp(b) == Equal contract.

Comment on lines +106 to 120
// Numeric ordering: 10 > 9 (lexicographic "10" < "9" was the old bug)
#[test]
fn test_numeric_ordering() {
let nine = ica("USD", "rA", "9");
let ten = ica("USD", "rA", "10");
assert!(ten > nine, "10 must be greater than 9 numerically");
}

// PartialOrd consistent with Ord
#[test]
fn test_partial_ord_consistent() {
let a = ica("USD", "rA", "50");
let b = ica("USD", "rA", "100");
assert_eq!(a.partial_cmp(&b), Some(Ordering::Less));
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 0630bc85: added test_eq_canonical_forms_numeric (verifies "100" and "100.0" are Eq-equal and Ord-Equal), test_ord_tiebreak_currency_when_value_equal (verifies EUR < USD when values are equal), and test_ord_malformed_value_not_silent_zero (verifies malformed values are neither equal to zero nor given a zero-equivalent sort position). All 8 tests pass.

Previously Ord::cmp only compared the value field lexicographically,
violating the invariant a.cmp(b) == Equal ↔ a == b. Two amounts with
identical value but different currency or issuer compared Equal by Ord
while being unequal by Eq, causing silent BTreeMap key collisions.
Lexicographic string ordering also gave wrong numeric results ("10" < "9").

Fix: compare value numerically via BigDecimal (already a dependency),
then break ties on currency and issuer to match the derived 3-field Eq.

Adds five regression tests covering the Ord/Eq invariant, numeric
ordering, and PartialOrd consistency.

Closes #348
…ce unwrap_or_default

Three issues from PR review addressed:

1. Eq/Ord consistency (Copilot #3581620787): derived Eq compared strings while
   Ord used BigDecimal numerics. Remove #[derive(PartialEq, Eq)] and implement
   manually — numeric comparison when both values are valid decimals, lexicographic
   fallback otherwise. '100' and '100.0' are now Eq-equal and Ord-Equal.

2. unwrap_or_default (depthfirst #3580984263): silently mapped malformed values
   to zero, misplacing them in sort order. Replace with explicit match — when
   either value fails BigDecimal parsing, fall back to lexicographic string
   comparison so malformed values are not treated as zero.

3. Edge-case tests (Copilot #3581620833): add tests for canonical form equality
   ('100' == '100.0'), currency tiebreak ordering, and the malformed-value
   non-zero guarantee.
Use BigDecimal::cmp instead of BigDecimal::PartialEq inside the eq
implementation so that the equality check and the ordering check go
through the same comparison operation. This eliminates any risk of a
scale-sensitive PartialEq diverging from cmp for values like "1.0" vs
"1.00", making the cmp == Equal ↔ eq invariant hold by construction.

Also add test_eq_and_ord_agree_for_different_decimal_repr to explicitly
cover the "1.0"/"1.00" case and prevent regression.
@e-desouza
e-desouza force-pushed the fix/issue-348-issued-currency-ord-eq branch from 0630bc8 to c540eca Compare July 15, 2026 16:31
Comment thread src/models/amount/issued_currency_amount.rs Outdated
… to cmp

Replace the single wildcard arm in Ord::cmp with four explicit arms so
that valid decimals always sort before malformed strings, eliminating the
mixed numeric/lexicographic regime that broke transitivity across the
parse boundary (a < b < c but a > c was reachable).

Collapse the PartialEq::eq match block to a one-line delegation to
self.cmp(other) so the Ord/Eq invariant holds by construction rather than
by parallel duplication that could silently diverge on future changes.
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.

bug(amount): IssuedCurrencyAmount Ord/Eq inconsistency — same value, different currency/issuer compares Equal

2 participants