diff --git a/crates/common/src/financial_types.rs b/crates/common/src/financial_types.rs index a8e44a95e..3b325b91c 100644 --- a/crates/common/src/financial_types.rs +++ b/crates/common/src/financial_types.rs @@ -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 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 for Price { impl Div 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 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]