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:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user