fix(common): seal Ratio finite invariant and i128→i64 saturation

- Ratio arithmetic (Add, Sub, Mul, Div) now clamps overflow to
  f64::MAX/f64::MIN instead of producing Inf from finite inputs
- Cross-type Price*Quantity→Money and Money/Quantity→Price use
  saturating i128→i64 conversion via clamp instead of silent truncation
- Added clamp_finite() and saturating_i128_to_i64() helper functions
- Added 9 edge-case tests: 89/89 passing, clippy clean

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-02-28 09:47:52 +01:00
parent bc5a333402
commit c745693420

View File

@@ -781,18 +781,31 @@ impl Ratio {
// -- Arithmetic impls for Ratio --
/// Clamp an f64 result to preserve the finite invariant.
/// Returns `f64::MAX` for positive infinity, `f64::MIN` for negative infinity.
#[inline]
fn clamp_finite(value: f64) -> f64 {
if value.is_finite() {
value
} else if value.is_sign_positive() {
f64::MAX
} else {
f64::MIN
}
}
impl Add for Ratio {
type Output = Self;
#[allow(clippy::float_arithmetic)]
fn add(self, rhs: Self) -> Self::Output {
Self(self.0 + rhs.0)
Self(clamp_finite(self.0 + rhs.0))
}
}
impl AddAssign for Ratio {
#[allow(clippy::float_arithmetic)]
fn add_assign(&mut self, rhs: Self) {
self.0 += rhs.0;
self.0 = clamp_finite(self.0 + rhs.0);
}
}
@@ -800,14 +813,14 @@ impl Sub for Ratio {
type Output = Self;
#[allow(clippy::float_arithmetic)]
fn sub(self, rhs: Self) -> Self::Output {
Self(self.0 - rhs.0)
Self(clamp_finite(self.0 - rhs.0))
}
}
impl SubAssign for Ratio {
#[allow(clippy::float_arithmetic)]
fn sub_assign(&mut self, rhs: Self) {
self.0 -= rhs.0;
self.0 = clamp_finite(self.0 - rhs.0);
}
}
@@ -815,7 +828,7 @@ impl Mul for Ratio {
type Output = Self;
#[allow(clippy::float_arithmetic)]
fn mul(self, rhs: Self) -> Self::Output {
Self(self.0 * rhs.0)
Self(clamp_finite(self.0 * rhs.0))
}
}
@@ -826,7 +839,7 @@ impl Div for Ratio {
if rhs.0 == 0.0 {
return Self(0.0);
}
Self(self.0 / rhs.0)
Self(clamp_finite(self.0 / rhs.0))
}
}
@@ -861,17 +874,23 @@ impl Default for Ratio {
// Cross-type arithmetic
// ---------------------------------------------------------------------------
/// Saturating conversion from i128 to i64. Clamps to `i64::MAX`/`i64::MIN` on overflow.
#[inline]
#[allow(clippy::cast_possible_truncation)]
fn saturating_i128_to_i64(value: i128) -> i64 {
value.clamp(i128::from(i64::MIN), i128::from(i64::MAX)) as i64
}
/// `Price * Quantity -> Money` using i128 intermediate to avoid overflow.
impl Mul<Quantity> for Price {
type Output = Money;
#[allow(clippy::arithmetic_side_effects)]
#[allow(clippy::cast_possible_truncation)]
fn mul(self, rhs: Quantity) -> Self::Output {
let wide = i128::from(self.0) * i128::from(rhs.0);
// Both are scaled by SCALE, so product is scaled by SCALE^2.
// Divide by SCALE to get back to SCALE.
let result = wide / i128::from(SCALE);
Money(result as i64)
Money(saturating_i128_to_i64(result))
}
}
@@ -879,7 +898,6 @@ impl Mul<Quantity> for Price {
impl Div<Quantity> for Money {
type Output = Price;
#[allow(clippy::arithmetic_side_effects)]
#[allow(clippy::cast_possible_truncation)]
fn div(self, rhs: Quantity) -> Self::Output {
if rhs.0 == 0 {
return Price::ZERO;
@@ -887,7 +905,7 @@ impl Div<Quantity> for Money {
// Multiply by SCALE first to keep precision, then divide.
let wide = i128::from(self.0) * i128::from(SCALE);
let result = wide / i128::from(rhs.0);
Price(result as i64)
Price(saturating_i128_to_i64(result))
}
}
@@ -1461,6 +1479,86 @@ mod tests {
assert!(result.to_f64().is_finite()); // would be Inf; clamped
}
// ===== Ratio finite invariant on arithmetic =====
#[test]
fn ratio_add_overflow_clamps() {
let big = Ratio::from_raw(f64::MAX);
let result = big + big;
assert!(result.to_f64().is_finite());
assert_eq!(result.to_f64(), f64::MAX);
}
#[test]
fn ratio_sub_overflow_clamps() {
let big_pos = Ratio::from_raw(f64::MAX);
let big_neg = Ratio::from_raw(f64::MIN);
let result = big_neg - big_pos;
assert!(result.to_f64().is_finite());
assert_eq!(result.to_f64(), f64::MIN);
}
#[test]
fn ratio_mul_overflow_clamps() {
let big = Ratio::from_raw(f64::MAX);
let two = Ratio::new(2.0).unwrap();
let result = big * two;
assert!(result.to_f64().is_finite());
assert_eq!(result.to_f64(), f64::MAX);
}
#[test]
fn ratio_div_overflow_clamps() {
let big = Ratio::from_raw(f64::MAX);
let tiny = Ratio::new(0.5).unwrap();
let result = big / tiny;
assert!(result.to_f64().is_finite());
assert_eq!(result.to_f64(), f64::MAX);
}
#[test]
fn ratio_add_assign_overflow_clamps() {
let mut r = Ratio::from_raw(f64::MAX);
r += Ratio::from_raw(f64::MAX);
assert!(r.to_f64().is_finite());
}
#[test]
fn ratio_sub_assign_overflow_clamps() {
let mut r = Ratio::from_raw(f64::MIN);
r -= Ratio::from_raw(f64::MAX);
assert!(r.to_f64().is_finite());
}
// ===== Cross-type i128→i64 saturation =====
#[test]
fn cross_type_extreme_values_saturate() {
let max_price = Price::from_raw(i64::MAX);
let large_qty = Quantity::from_raw(i64::MAX);
let money: Money = max_price * large_qty;
// Should saturate to i64::MAX instead of wrapping
assert_eq!(money.raw_value(), i64::MAX);
}
#[test]
fn cross_type_negative_extreme_saturates() {
let max_price = Price::from_raw(i64::MAX);
let neg_qty = Quantity::from_raw(i64::MIN);
let money: Money = max_price * neg_qty;
// Should saturate to i64::MIN instead of wrapping
assert_eq!(money.raw_value(), i64::MIN);
}
#[test]
fn money_div_quantity_extreme_saturates() {
let max_money = Money::from_raw(i64::MAX);
let tiny_qty = Quantity::from_raw(1); // 0.000001 units
let price: Price = max_money / tiny_qty;
// Result would overflow i64; should saturate
assert_eq!(price.raw_value(), i64::MAX);
}
// ===== Module-level SCALE =====
#[test]