diff --git a/src/constants.rs b/src/constants.rs index 1da6a68..d00afba 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -3,12 +3,11 @@ //! This module provides cached instances of frequently used Number values //! to reduce allocations in hot paths. -#[cfg(feature = "decimal")] -use std::sync::Arc; - #[cfg(feature = "decimal")] use rust_decimal::Decimal; +#[cfg(feature = "decimal")] +use crate::decimal_pool::create_decimal_arc; use crate::float::Float64; use crate::types::Number; @@ -69,9 +68,9 @@ define_common_numbers! { // Decimal constants (when feature enabled) #[cfg(feature = "decimal")] - decimal_zero: Decimal(Arc::new(Decimal::ZERO)), + decimal_zero: Decimal(create_decimal_arc(Decimal::ZERO)), #[cfg(feature = "decimal")] - decimal_one: Decimal(Arc::new(Decimal::ONE)), + decimal_one: Decimal(create_decimal_arc(Decimal::ONE)), } /// Get cached zero value for a given type based on a reference Number diff --git a/src/conversions/from_impls.rs b/src/conversions/from_impls.rs index 96d87d6..ac16710 100644 --- a/src/conversions/from_impls.rs +++ b/src/conversions/from_impls.rs @@ -1,11 +1,10 @@ //! From trait implementations for converting to Number. -#[cfg(feature = "decimal")] -use std::sync::Arc; - #[cfg(feature = "decimal")] use rust_decimal::Decimal; +#[cfg(feature = "decimal")] +use crate::decimal_pool::create_decimal_arc; use crate::{Float64, Number}; // Generate From trait implementations for integer types using macro @@ -63,7 +62,7 @@ impl From for Number { #[cfg(feature = "decimal")] impl From for Number { fn from(n: Decimal) -> Self { - Self::Decimal(Arc::new(n)) + Self::Decimal(create_decimal_arc(n)) } } diff --git a/src/conversions/parsing.rs b/src/conversions/parsing.rs index 07406ed..0ceb529 100644 --- a/src/conversions/parsing.rs +++ b/src/conversions/parsing.rs @@ -1,13 +1,14 @@ //! String parsing utilities and implementations. +use std::borrow::Cow; use std::convert::TryFrom; -#[cfg(feature = "decimal")] -use std::sync::Arc; #[cfg(feature = "decimal")] use rust_decimal::Decimal; use super::errors::ParseNumberError; +#[cfg(feature = "decimal")] +use crate::decimal_pool::create_decimal_arc; use crate::{Float64, Number}; #[inline] @@ -24,21 +25,32 @@ fn validate_underscores(s: &str) -> bool { return true; } - let is_hex = bytes.len() >= 2 && bytes[0] == b'0' && (bytes[1] | 32) == b'x'; + let (offset, bytes_without_sign) = if matches!(bytes.first(), Some(b'+' | b'-')) { + (1, &bytes[1..]) + } else { + (0, bytes) + }; + + let is_hex = bytes_without_sign.len() >= 2 + && bytes_without_sign[0] == b'0' + && (bytes_without_sign[1] | 32) == b'x'; let has_scientific = if is_hex { - s.bytes().any(|b| (b | 32) == b'p') + bytes_without_sign.iter().any(|&b| (b | 32) == b'p') } else { - s.bytes().any(|b| (b | 32) == b'e') + bytes_without_sign.iter().any(|&b| (b | 32) == b'e') }; let e_pos_opt = if has_scientific { - bytes.iter().position(|&b| { - if is_hex { - (b | 32) == b'p' - } else { - (b | 32) == b'e' - } - }) + bytes_without_sign + .iter() + .position(|&b| { + if is_hex { + (b | 32) == b'p' + } else { + (b | 32) == b'e' + } + }) + .map(|pos| pos + offset) } else { None }; @@ -79,11 +91,15 @@ fn validate_underscores(s: &str) -> bool { #[inline] #[must_use] -fn remove_valid_underscores(s: &str) -> Option { +fn remove_valid_underscores(s: &str) -> Option> { if !validate_underscores(s) { return None; } - Some(s.replace('_', "")) + if s.contains('_') { + Some(Cow::Owned(s.replace('_', ""))) + } else { + Some(Cow::Borrowed(s)) + } } #[inline] @@ -123,7 +139,7 @@ fn parse_unsigned_u128(input: &str) -> Option { } let s = remove_valid_underscores(s)?; - let (base, digits) = detect_base_and_digits(&s); + let (base, digits) = detect_base_and_digits(s.as_ref()); if digits.is_empty() { return None; } @@ -146,7 +162,7 @@ fn parse_signed_i128(input: &str) -> Option { } let s = remove_valid_underscores(s)?; - let (base, digits) = detect_base_and_digits(&s); + let (base, digits) = detect_base_and_digits(s.as_ref()); if digits.is_empty() { return None; } @@ -238,20 +254,21 @@ impl TryFrom<&str> for Number { let Some(sanitized) = remove_valid_underscores(trimmed) else { return Err(ParseNumberError::InvalidFormat(trimmed.to_string())); }; - let has_decimal = sanitized.contains('.'); + let sanitized_str = sanitized.as_ref(); + let has_decimal = sanitized_str.contains('.'); if !has_decimal && let Some(result) = - try_parse_integer_with_promotion(&sanitized, trimmed.starts_with('-')) + try_parse_integer_with_promotion(sanitized_str, trimmed.starts_with('-')) { return result; } - if let Ok(n) = sanitized.parse::() { + if let Ok(n) = sanitized_str.parse::() { #[cfg(feature = "decimal")] { - if let Ok(decimal) = sanitized.parse::() { - return Ok(Self::Decimal(Arc::new(decimal))); + if let Ok(decimal) = sanitized_str.parse::() { + return Ok(Self::Decimal(create_decimal_arc(decimal))); } if n.is_finite() @@ -260,7 +277,7 @@ impl TryFrom<&str> for Number { && n.abs() < 1e28 && let Ok(decimal) = rust_decimal::Decimal::try_from(n) { - return Ok(Self::Decimal(Arc::new(decimal))); + return Ok(Self::Decimal(create_decimal_arc(decimal))); } return Ok(Self::F64(Float64(n))); diff --git a/src/conversions/try_from_impls.rs b/src/conversions/try_from_impls.rs index 320e0b0..455e0f5 100644 --- a/src/conversions/try_from_impls.rs +++ b/src/conversions/try_from_impls.rs @@ -7,23 +7,22 @@ use super::errors::TryFromNumberError; use crate::Number; // Boundaries and exactness limits for f64 integer conversions/readability -// 2^64, 2^63, and -(2^63 + 1) as f64 with readable separators -const F64_OVERFLOW_U64_BOUNDARY: f64 = 18_446_744_073_709_551_616.0; // 2^64 -const F64_U64_MAX_APPROX: f64 = 18_446_744_073_709_551_615.0; // 2^64 - 1 -const F64_I64_MAX_APPROX: f64 = 9_223_372_036_854_775_807.0; // 2^63 - 1 -const F64_I64_MIN_APPROX: f64 = -9_223_372_036_854_775_808.0; // -2^63 -const F64_OVERFLOW_I64_POS_BOUNDARY: f64 = 9_223_372_036_854_775_808.0; // 2^63 -const F64_OVERFLOW_I64_NEG_BOUNDARY: f64 = -9_223_372_036_854_775_809.0; // -(2^63 + 1) +const U64_F64_EXCL_MAX: f64 = 18_446_744_073_709_551_616.0; // 2^64 +const I64_F64_MIN: f64 = -9_223_372_036_854_775_808.0; // -2^63 +const I64_F64_EXCL_MAX: f64 = 9_223_372_036_854_775_808.0; // 2^63 // Largest integer exactly representable in f64 mantissa (2^53) const F64_EXACT_INT_LIMIT_U64: u64 = 1u64 << 53; -const F64_EXACT_INT_LIMIT_I64: i64 = 1i64 << 53; /// Helper functions for float conversions #[inline] -#[allow(clippy::float_cmp)] +#[allow( + clippy::float_cmp, + clippy::cast_possible_truncation, + clippy::cast_sign_loss +)] pub fn validate_float_to_unsigned(f: f64, max: u64) -> Result { if !f.is_finite() { - if f.is_nan() { + return if f.is_nan() { Err(TryFromNumberError::TypeMismatch { from: "NaN", to: "unsigned integer", @@ -33,132 +32,74 @@ pub fn validate_float_to_unsigned(f: f64, max: u64) -> Result F64_U64_MAX_APPROX { - return Err(TryFromNumberError::OutOfRange { - value: f.to_string(), - target: "u64", - }); - } - - if f.fract() != 0.0 { - return Err(TryFromNumberError::TypeMismatch { - from: "float with fractional part", - to: "integer", - }); - } - - // Convert to integer - let as_int = { - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - { - f as u64 - } - }; - - // Check if we hit the exact boundary where clamping occurs - if as_int == u64::MAX { - // Check if the original float represents a value larger than u64::MAX - // The smallest f64 that overflows is 2^64 - if f >= F64_OVERFLOW_U64_BOUNDARY { - return Err(TryFromNumberError::OutOfRange { - value: f.to_string(), - target: "u64", - }); - } - } - - // Check range first for small target types - if as_int > max { - return Err(TryFromNumberError::OutOfRange { - value: as_int.to_string(), - target: "target type", - }); - } + }); + } + if f.fract() != 0.0 { + return Err(TryFromNumberError::TypeMismatch { + from: "float with fractional part", + to: "integer", + }); + } + if f >= U64_F64_EXCL_MAX { + return Err(TryFromNumberError::OutOfRange { + value: f.to_string(), + target: "u64", + }); + } - Ok(as_int) + let as_int = f as u64; // now guaranteed non-saturating + if as_int > max { + return Err(TryFromNumberError::OutOfRange { + value: as_int.to_string(), + target: "target type", + }); } + Ok(as_int) } #[inline] -#[allow(clippy::float_cmp)] +#[allow(clippy::float_cmp, clippy::cast_possible_truncation)] pub fn validate_float_to_signed(f: f64, min: i64, max: i64) -> Result { - if f.is_finite() { - // Check if the float is obviously out of range before conversion - // This catches very large numbers that would overflow during f as i64 - if !(F64_I64_MIN_APPROX..=F64_I64_MAX_APPROX).contains(&f) { - return Err(TryFromNumberError::OutOfRange { + if !f.is_finite() { + return if f.is_nan() { + Err(TryFromNumberError::TypeMismatch { + from: "NaN", + to: "signed integer", + }) + } else { + Err(TryFromNumberError::OutOfRange { value: f.to_string(), - target: "i64", - }); - } - - if f.fract() != 0.0 { - return Err(TryFromNumberError::TypeMismatch { - from: "float with fractional part", - to: "integer", - }); - } - - // Convert to integer - let as_int = { - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - { - f as i64 - } + target: "signed integer", + }) }; - - // Check if we hit the exact boundaries where clamping occurs - // This is tricky because i64::MAX loses precision when converted to f64 - if as_int == i64::MAX { - // Check if the original float represents a value larger than i64::MAX - // We do this by checking if f is >= the smallest f64 that converts to overflow - // The smallest f64 that overflows is i64::MAX + 0.5 when rounded - if f >= F64_OVERFLOW_I64_POS_BOUNDARY { - return Err(TryFromNumberError::OutOfRange { - value: f.to_string(), - target: "i64", - }); - } - } - if as_int == i64::MIN { - // Similar check for the negative boundary - if f <= F64_OVERFLOW_I64_NEG_BOUNDARY { - return Err(TryFromNumberError::OutOfRange { - value: f.to_string(), - target: "i64", - }); - } - } - - // Check range first for small target types - if as_int < min || as_int > max { - return Err(TryFromNumberError::OutOfRange { - value: as_int.to_string(), - target: "target type", - }); - } - - Ok(as_int) - } else if f.is_nan() { - Err(TryFromNumberError::TypeMismatch { - from: "NaN", - to: "signed integer", - }) - } else { - Err(TryFromNumberError::OutOfRange { + } + if f.fract() != 0.0 { + return Err(TryFromNumberError::TypeMismatch { + from: "float with fractional part", + to: "integer", + }); + } + if !(I64_F64_MIN..I64_F64_EXCL_MAX).contains(&f) { + return Err(TryFromNumberError::OutOfRange { value: f.to_string(), - target: "signed integer", - }) + target: "i64", + }); + } + + let as_int = f as i64; // now guaranteed non-saturating + if as_int < min || as_int > max { + return Err(TryFromNumberError::OutOfRange { + value: as_int.to_string(), + target: "target type", + }); } + Ok(as_int) } /// Macro for generating `TryFrom` implementations for unsigned types @@ -326,35 +267,33 @@ impl TryFrom for f64 { use Number::{F64, I64, U64}; match value { F64(n) => Ok(n.0), - // f64 can exactly represent integers up to 2^53 U64(n) => { + let f = Number::lossy_u64_to_f64(n); if n <= F64_EXACT_INT_LIMIT_U64 { - Ok(Number::lossy_u64_to_f64(n)) + return Ok(f); + } + if f < U64_F64_EXCL_MAX && Number::lossy_f64_to_u64(f) == n { + Ok(f) } else { - let f = Number::lossy_u64_to_f64(n); - if Number::lossy_f64_to_u64(f) == n { - Ok(f) - } else { - Err(TryFromNumberError::TypeMismatch { - from: "u64 > 2^53", - to: "f64 (precision loss)", - }) - } + Err(TryFromNumberError::TypeMismatch { + from: "u64 > 2^53", + to: "f64 (precision loss)", + }) } } I64(n) => { - if n.abs() <= F64_EXACT_INT_LIMIT_I64 { - Ok(Number::lossy_i64_to_f64(n)) + let f = Number::lossy_i64_to_f64(n); + let abs = n.unsigned_abs(); + if abs <= F64_EXACT_INT_LIMIT_U64 { + return Ok(f); + } + if (n < 0 || f < I64_F64_EXCL_MAX) && Number::lossy_f64_to_i64(f) == n { + Ok(f) } else { - let f = Number::lossy_i64_to_f64(n); - if Number::lossy_f64_to_i64(f) == n { - Ok(f) - } else { - Err(TryFromNumberError::TypeMismatch { - from: "i64 > 2^53", - to: "f64 (precision loss)", - }) - } + Err(TryFromNumberError::TypeMismatch { + from: "i64 > 2^53", + to: "f64 (precision loss)", + }) } } #[cfg(feature = "decimal")] diff --git a/src/decimal_pool.rs b/src/decimal_pool.rs index 040d017..8b9d5c7 100644 --- a/src/decimal_pool.rs +++ b/src/decimal_pool.rs @@ -56,6 +56,11 @@ impl CommonDecimals { /// or falls back to direct allocation for uncommon values. #[inline] pub fn create_decimal_arc(value: Decimal) -> Arc { + // Preserve the sign bit for negative zero by skipping cache lookups + if value.is_zero() && value.is_sign_negative() { + return Arc::new(value); + } + // Fast path: check for common values if let Some(cached) = COMMON_DECIMALS.get_cached(&value) { return cached; diff --git a/src/lib.rs b/src/lib.rs index c1e1bc0..d305840 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -118,8 +118,8 @@ pub use math::RoundingStrategy; pub use ops::bitwise::BitwiseError; pub use types::Number; -const I128_RANGE_MIN: f64 = -170_141_183_460_469_231_731_687_303_715_884_105_728.0; -const I128_RANGE_MAX: f64 = 170_141_183_460_469_231_731_687_303_715_884_105_727.0; +const I128_F64_MIN: f64 = -170_141_183_460_469_231_731_687_303_715_884_105_728.0; // -2^127 +const I128_F64_EXCL_MAX: f64 = 170_141_183_460_469_231_731_687_303_715_884_105_728.0; // 2^127 impl Number { // Getter methods for integer types @@ -141,7 +141,7 @@ impl Number { Self::I64(n) => Some(i128::from(*n)), Self::F64(n) => { let f = n.0; - if f.fract() == 0.0 && (I128_RANGE_MIN..=I128_RANGE_MAX).contains(&f) { + if f.fract() == 0.0 && (I128_F64_MIN..I128_F64_EXCL_MAX).contains(&f) { #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] { Some(f as i128) diff --git a/src/macros/arithmetic.rs b/src/macros/arithmetic.rs index 4ff34a7..bde4012 100644 --- a/src/macros/arithmetic.rs +++ b/src/macros/arithmetic.rs @@ -72,9 +72,12 @@ macro_rules! impl_div_integer { use rust_decimal::Decimal; let lhs_decimal = Decimal::from(*$a); let rhs_decimal = Decimal::from(*$b); - Self::Decimal(crate::decimal_pool::create_decimal_arc( - lhs_decimal / rhs_decimal, - )) + crate::ops::arithmetic::div::decimal_div_or_f64( + lhs_decimal, + rhs_decimal, + <$type as crate::fast_dispatch::LossyToF64>::lossy_to_f64(*$a) + / <$type as crate::fast_dispatch::LossyToF64>::lossy_to_f64(*$b), + ) } #[cfg(not(feature = "decimal"))] Self::F64(crate::float::Float64( @@ -109,9 +112,12 @@ macro_rules! impl_div_signed_integer { use rust_decimal::Decimal; let lhs_decimal = Decimal::from(*$a); let rhs_decimal = Decimal::from(*$b); - Self::Decimal(crate::decimal_pool::create_decimal_arc( - lhs_decimal / rhs_decimal, - )) + crate::ops::arithmetic::div::decimal_div_or_f64( + lhs_decimal, + rhs_decimal, + <$type as crate::fast_dispatch::LossyToF64>::lossy_to_f64(*$a) + / <$type as crate::fast_dispatch::LossyToF64>::lossy_to_f64(*$b), + ) } #[cfg(not(feature = "decimal"))] Self::F64(crate::float::Float64( diff --git a/src/ops/arithmetic/div.rs b/src/ops/arithmetic/div.rs index 67fb26f..0dd94c9 100644 --- a/src/ops/arithmetic/div.rs +++ b/src/ops/arithmetic/div.rs @@ -13,6 +13,19 @@ use crate::float::Float64; use crate::macros::arithmetic::{impl_div_integer, impl_div_signed_integer}; use crate::types::Number; +#[cfg(feature = "decimal")] +#[inline] +pub fn decimal_div_or_f64( + lhs: rust_decimal::Decimal, + rhs: rust_decimal::Decimal, + fallback: f64, +) -> Number { + lhs.checked_div(rhs) + .map_or(Number::F64(Float64(fallback)), |d| { + Number::Decimal(create_decimal_arc(d)) + }) +} + // Manual Div implementation with special handling for exact vs inexact division impl Div for Number { type Output = Self; @@ -44,7 +57,7 @@ impl Div for Number { if let (Ok(a_dec), Ok(b_dec)) = (Decimal::try_from(a.0), Decimal::try_from(b.0)) { - return Self::Decimal(create_decimal_arc(a_dec / b_dec)); + return decimal_div_or_f64(a_dec, b_dec, float_value.0); } } Self::F64(float_value) diff --git a/src/ops/arithmetic/mod.rs b/src/ops/arithmetic/mod.rs index 3c9b63a..df22e8c 100644 --- a/src/ops/arithmetic/mod.rs +++ b/src/ops/arithmetic/mod.rs @@ -77,7 +77,7 @@ mod mixed_ops; // Operation modules mod add; -mod div; +pub mod div; mod mul; mod neg; mod pow; diff --git a/src/ops/arithmetic/pow.rs b/src/ops/arithmetic/pow.rs index e715e1b..b13c0f5 100644 --- a/src/ops/arithmetic/pow.rs +++ b/src/ops/arithmetic/pow.rs @@ -13,7 +13,7 @@ use crate::types::Number; impl Number { /// Optimized exponentiation by squaring with minimal allocations. #[inline] - fn pow_by_squaring_u64(&self, mut exp: u64) -> Self { + fn pow_by_squaring_uint(&self, mut exp: u128) -> Self { if exp == 0 { return self.one(); } @@ -87,6 +87,8 @@ impl Number { #[must_use] #[allow(clippy::float_cmp)] pub fn pow(&self, exponent: &Self) -> Self { + let exponent_int = exponent.as_i128(); + // Special case: anything to the power of 0 is 1 if exponent.is_zero() { return self.one(); @@ -110,60 +112,64 @@ impl Number { Self::Decimal(d) => d.is_sign_negative(), _ => self.is_negative(), }; - return if exponent.is_positive() { - let exp_is_odd = exponent.try_get_u64().map_or_else( - || { - exponent + let zero_pow_result = exponent_int.map_or_else( + || { + if exponent.is_positive() { + if base_sign_negative { + match self { + Self::F64(_) => Self::from(0.0f64), + #[cfg(feature = "decimal")] + Self::Decimal(_) => Self::from(rust_decimal::Decimal::ZERO), + _ => self.zero(), + } + } else { + self.zero() + } + } else { + let exp_is_odd_negative_int = exponent .try_get_i64() - .is_some_and(|exp_i64| exp_i64 > 0 && exp_i64 % 2 != 0) - }, - |exp_u64| exp_u64 % 2 != 0, - ); - - if base_sign_negative && exp_is_odd { - // -0^odd = -0 - self.zero() - } else if base_sign_negative { - // -0^even = +0 - match self { - Self::F64(_) => Self::from(0.0f64), - #[cfg(feature = "decimal")] - Self::Decimal(_) => Self::from(rust_decimal::Decimal::ZERO), - _ => self.zero(), + .is_some_and(|exp_i64| exp_i64 < 0 && exp_i64 % 2 != 0); + if base_sign_negative && exp_is_odd_negative_int { + get_cached_f64_neg_infinity().clone() + } else { + get_cached_f64_infinity().clone() + } } - } else { - // Regular zero - preserve type - self.zero() - } - } else { - // 0 to negative power is infinity - // For negative zero raised to odd negative power, return negative infinity - let exp_is_odd_negative_int = exponent - .try_get_i64() - .is_some_and(|exp_i64| exp_i64 < 0 && exp_i64 % 2 != 0); - if base_sign_negative && exp_is_odd_negative_int { - get_cached_f64_neg_infinity().clone() - } else { - get_cached_f64_infinity().clone() - } - }; + }, + |exp_int| { + let exp_is_odd = exp_int & 1 != 0; + if exp_int > 0 { + if base_sign_negative && exp_is_odd { + self.zero() + } else if base_sign_negative { + match self { + Self::F64(_) => Self::from(0.0f64), + #[cfg(feature = "decimal")] + Self::Decimal(_) => Self::from(rust_decimal::Decimal::ZERO), + _ => self.zero(), + } + } else { + self.zero() + } + } else if base_sign_negative && exp_is_odd { + get_cached_f64_neg_infinity().clone() + } else { + get_cached_f64_infinity().clone() + } + }, + ); + return zero_pow_result; } - // For integer exponents, use optimized repeated multiplication - if let Some(exp_u64) = exponent.try_get_u64() { - if exp_u64 == 1 { + if let Some(exp_int) = exponent_int { + if exp_int == 1 { return self.clone(); } + if exp_int > 0 { + return self.pow_by_squaring_uint(exp_int.unsigned_abs()); + } - // Optimized exponentiation by squaring with minimal allocations - return self.pow_by_squaring_u64(exp_u64); - } - - // For negative integer exponents, compute positive power then take reciprocal - if let Some(exp_i64) = exponent.try_get_i64() - && exp_i64 < 0 - { - let positive_result = self.pow_by_squaring_u64(exp_i64.unsigned_abs()); + let positive_result = self.pow_by_squaring_uint(exp_int.unsigned_abs()); return Self::U64(1) / positive_result; } diff --git a/src/ref_ops.rs b/src/ref_ops.rs index 3245149..ac969ce 100644 --- a/src/ref_ops.rs +++ b/src/ref_ops.rs @@ -15,6 +15,8 @@ use rust_decimal::Decimal; use crate::decimal_pool::create_decimal_arc; use crate::fast_dispatch::LossyToF64; use crate::float::Float64; +#[cfg(feature = "decimal")] +use crate::ops::arithmetic::div::decimal_div_or_f64; use crate::types::Number; /// Macro to handle decimal operations with fallback to f64 @@ -117,7 +119,11 @@ impl Number { { let lhs_decimal = Decimal::from(*a); let rhs_decimal = Decimal::from(*b); - Self::Decimal(create_decimal_arc(lhs_decimal / rhs_decimal)) + decimal_div_or_f64( + lhs_decimal, + rhs_decimal, + (*a).lossy_to_f64() / (*b).lossy_to_f64(), + ) } #[cfg(not(feature = "decimal"))] { @@ -146,7 +152,11 @@ impl Number { { let lhs_decimal = Decimal::from(*a); let rhs_decimal = Decimal::from(*b); - Self::Decimal(create_decimal_arc(lhs_decimal / rhs_decimal)) + decimal_div_or_f64( + lhs_decimal, + rhs_decimal, + (*a).lossy_to_f64() / (*b).lossy_to_f64(), + ) } #[cfg(not(feature = "decimal"))] { @@ -170,7 +180,7 @@ impl Number { Decimal::try_from(a.0), Decimal::try_from(b.0), ) { - return Self::Decimal(create_decimal_arc(a_dec / b_dec)); + return decimal_div_or_f64(a_dec, b_dec, float_value.0); } Self::F64(float_value) } else { diff --git a/src/traits/convert.rs b/src/traits/convert.rs index bf4d814..e69e45d 100644 --- a/src/traits/convert.rs +++ b/src/traits/convert.rs @@ -59,24 +59,3 @@ impl ToI128 for usize { i128::from(self as u64) } } - -#[cfg(test)] -mod tests { - use super::ToI128; - - #[test] - fn primitive_conversions_are_lossless() { - assert_eq!(123_i32.to_i128(), 123); - assert_eq!((-456_i64).to_i128(), -456); - assert_eq!(789_u32.to_i128(), 789); - assert_eq!(u64::MAX.to_i128(), i128::from(u64::MAX)); - } - - #[test] - fn pointer_sized_conversions_follow_architecture() { - let signed: isize = -42; - let unsigned: usize = 42; - assert_eq!(signed.to_i128(), -42); - assert_eq!(unsigned.to_i128(), 42); - } -} diff --git a/src/traits/equality.rs b/src/traits/equality.rs index 8cd2c18..37af687 100644 --- a/src/traits/equality.rs +++ b/src/traits/equality.rs @@ -6,6 +6,12 @@ impl Number { /// Helper function for equality comparison using canonical form. #[must_use] pub(crate) fn eq_canonical(&self, other: &Self) -> bool { + // Exact decimal comparison to avoid lossy f64 collapse + #[cfg(feature = "decimal")] + if let (Self::Decimal(a), Self::Decimal(b)) = (self, other) { + return a == b; + } + // Integer comparison using i128 if self.is_integer() && other.is_integer() @@ -78,26 +84,3 @@ impl PartialEq for Number { } impl Eq for Number {} - -#[cfg(test)] -mod tests { - use super::Number; - - #[test] - fn eq_with_i128_prefers_integer_path() { - let n = Number::from(42u64); - assert!(n.eq_with_i128(42)); - assert!(!n.eq_with_i128(43)); - } - - #[test] - fn eq_with_f64_handles_integral_and_fractional_inputs() { - let integer = Number::from(10u64); - assert!(integer.eq_with_f64(10.0)); - assert!(!integer.eq_with_f64(11.0)); - - let fractional = Number::from(2.5); - assert!(fractional.eq_with_f64(2.5)); - assert!(!fractional.eq_with_f64(3.0)); - } -} diff --git a/src/traits/hashing.rs b/src/traits/hashing.rs index 3b16d00..d69ddce 100644 --- a/src/traits/hashing.rs +++ b/src/traits/hashing.rs @@ -6,9 +6,8 @@ use crate::Number; impl Hash for Number { fn hash(&self, state: &mut H) { - // Use the same strategy as eq_canonical for consistency - - // Hash integers using i128 representation + // Use the same strategy as eq_canonical for consistency. + // Prefer exact integer hashing when possible. if self.is_integer() && let Some(int_value) = self.as_i128() { diff --git a/src/traits/mod.rs b/src/traits/mod.rs index 8d9862f..415363d 100644 --- a/src/traits/mod.rs +++ b/src/traits/mod.rs @@ -7,6 +7,3 @@ pub mod convert; pub mod equality; pub mod hashing; pub mod ordering; - -// No re-exports here as these are trait implementations -// They are automatically available when the traits are in scope diff --git a/src/traits/ordering.rs b/src/traits/ordering.rs index f14c1c1..8f10f06 100644 --- a/src/traits/ordering.rs +++ b/src/traits/ordering.rs @@ -83,24 +83,3 @@ impl Number { } } } - -#[cfg(test)] -mod tests { - use super::Number; - use std::cmp::Ordering; - - #[test] - fn cmp_with_i128_prefers_integer_branch() { - let positive = Number::from(100u64); - assert_eq!(positive.cmp_with_i128(50), Ordering::Greater); - assert_eq!(positive.cmp_with_i128(150), Ordering::Less); - } - - #[test] - fn cmp_with_f64_handles_integral_and_float_values() { - let n = Number::from(12u64); - assert_eq!(n.cmp_with_f64(12.0), Ordering::Equal); - assert_eq!(n.cmp_with_f64(9.5), Ordering::Greater); - assert_eq!(n.cmp_with_f64(15.5), Ordering::Less); - } -} diff --git a/tests/conversions.rs b/tests/conversions.rs index 04c8d43..c997c92 100644 --- a/tests/conversions.rs +++ b/tests/conversions.rs @@ -20,3 +20,6 @@ mod accessor_methods; #[path = "conversions/edge_cases.rs"] mod edge_cases; + +#[path = "conversions/float_boundaries.rs"] +mod float_boundaries; diff --git a/tests/conversions/errors.rs b/tests/conversions/errors.rs index 7fd94f4..6b537c9 100644 --- a/tests/conversions/errors.rs +++ b/tests/conversions/errors.rs @@ -365,6 +365,10 @@ fn test_underscore_validation() { assert!(Number::try_from("0x1a_2b_3c").is_ok()); // Multiple hex separations assert!(Number::try_from("1_234_567").is_ok()); // Multiple decimal // separations + assert_eq!( + Number::try_from("-0xFF_FF").unwrap(), + Number::from(-65_535i64) + ); // FIXED: Implementation now validates underscore placement properly // Only allows underscores between digits for readability diff --git a/tests/conversions/float_boundaries.rs b/tests/conversions/float_boundaries.rs new file mode 100644 index 0000000..4e0bbb9 --- /dev/null +++ b/tests/conversions/float_boundaries.rs @@ -0,0 +1,31 @@ +use std::convert::TryFrom; + +use uninum::{Number, TryFromNumberError}; + +#[test] +fn f64_to_i64_accepts_min_boundary() { + let min_as_float = -9_223_372_036_854_775_808.0_f64; // -2^63 + let value = i64::try_from(Number::from(min_as_float)).expect("i64::MIN should convert"); + assert_eq!(value, i64::MIN); +} + +#[test] +fn f64_to_i64_rejects_exclusive_max_boundary() { + let err = i64::try_from(Number::from(9_223_372_036_854_775_808.0_f64)).unwrap_err(); + assert!(matches!(err, TryFromNumberError::OutOfRange { target, .. } if target == "i64")); +} + +#[test] +fn f64_to_u64_rejects_exclusive_max_boundary() { + let err = u64::try_from(Number::from(18_446_744_073_709_551_616.0_f64)).unwrap_err(); + assert!(matches!(err, TryFromNumberError::OutOfRange { target, .. } if target == "u64")); +} + +#[test] +fn try_from_number_for_f64_rejects_non_representable_ints() { + let err = f64::try_from(Number::from(u64::MAX)).unwrap_err(); + assert!(matches!(err, TryFromNumberError::TypeMismatch { .. })); + + let err = f64::try_from(Number::from(i64::MAX)).unwrap_err(); + assert!(matches!(err, TryFromNumberError::TypeMismatch { .. })); +} diff --git a/tests/conversions/try_from_impls.rs b/tests/conversions/try_from_impls.rs index c25f4b9..f97ef33 100644 --- a/tests/conversions/try_from_impls.rs +++ b/tests/conversions/try_from_impls.rs @@ -386,7 +386,7 @@ fn test_try_from_to_i64() { // From F64: validate_signed // Similar for F64 - let f64_large = num!(i64::MAX as f64 + 1.0); + let f64_large = Number::from(i64::MAX as f64 + 1.0); assert!(matches!( i64::try_from(f64_large), Err(TryFromNumberError::OutOfRange { .. }) @@ -398,15 +398,18 @@ fn test_try_from_to_i64() { )); // For boundary where as_int == i64::MAX but f >= 2^63 - let boundary = num!(9223372036854775808.0f64); // 2^63 + let boundary = Number::from(9223372036854775808.0f64); // 2^63 assert!(matches!( i64::try_from(boundary), Err(TryFromNumberError::OutOfRange { .. }) )); - let neg_boundary = num!(-9223372036854775809.0f64); // -(2^63 + 1) + let neg_boundary = Number::from(-9223372036854775809.0f64); // rounds to -2^63 + assert_eq!(i64::try_from(neg_boundary).unwrap(), i64::MIN); + + let far_below_min = Number::from(-1e20f64); assert!(matches!( - i64::try_from(neg_boundary), + i64::try_from(far_below_min), Err(TryFromNumberError::OutOfRange { .. }) )); @@ -636,15 +639,18 @@ fn test_boundary_conditions() { assert!(result.is_ok() || matches!(result, Err(TryFromNumberError::OutOfRange { .. }))); // Clamp boundaries - let at_clamp_boundary = num!(18446744073709551616.0f64); // 2^64 + let at_clamp_boundary = Number::from(18446744073709551616.0f64); // 2^64 assert!(matches!( u64::try_from(at_clamp_boundary), Err(TryFromNumberError::OutOfRange { .. }) )); - let at_neg_clamp_boundary = num!(-9223372036854775809.0f64); // -(2^63 + 1) + let at_neg_clamp_boundary = Number::from(-9223372036854775809.0f64); // rounds to -2^63 + assert_eq!(i64::try_from(at_neg_clamp_boundary).unwrap(), i64::MIN); + + let clearly_out_of_range = Number::from(-1e20f64); assert!(matches!( - i64::try_from(at_neg_clamp_boundary), + i64::try_from(clearly_out_of_range), Err(TryFromNumberError::OutOfRange { .. }) )); } diff --git a/tests/ops/arithmetic/pow.rs b/tests/ops/arithmetic/pow.rs index 6ea2cc4..ca646fe 100644 --- a/tests/ops/arithmetic/pow.rs +++ b/tests/ops/arithmetic/pow.rs @@ -483,6 +483,14 @@ fn test_pow_negative_zero() { assert_eq!(result, num!(f64::INFINITY)); } +/// Negative zero with float exponent that is an integer +#[test] +fn test_pow_negative_zero_with_float_integer_exponent() { + let neg_zero = num!(-0.0); + let result = neg_zero.pow(&num!(3.0)); + assert!(matches!(result.try_get_f64(), Some(v) if v == 0.0 && v.is_sign_negative())); +} + /// Tests more special float cases #[test] fn test_pow_more_special_floats() { @@ -552,6 +560,12 @@ fn test_pow_negative_base_edge_cases() { Number::from(625i64) ); + // Positive i64 exponent should use integer path + assert_eq!( + Number::from(3i64).pow(&Number::from(2i64)), + Number::from(9i64) + ); + // Negative float base with integer exponent assert_eq!(num!(-2.5).pow(&Number::from(2u64)), num!(6.25)); assert_eq!(num!(-2.5).pow(&Number::from(3u64)), num!(-15.625)); @@ -617,6 +631,11 @@ fn test_pow_with_decimal() { assert_eq!(**d, Decimal::from(1)); } + // Integer-like decimal exponent should follow integer path + let dec_exp = Number::from(Decimal::new(3, 0)); + let result = Number::from(-2i64).pow(&dec_exp); + assert_eq!(result, Number::from(-8i64)); + // Decimal to negative power let two = Number::from(Decimal::new(2, 0)); let result = two.pow(&Number::from(-2i64)); diff --git a/tests/traits/hashing.rs b/tests/traits/hashing.rs index 64fb678..50e0b9c 100644 --- a/tests/traits/hashing.rs +++ b/tests/traits/hashing.rs @@ -9,6 +9,8 @@ use std::collections::HashSet; use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; +#[cfg(feature = "decimal")] +use std::str::FromStr; use proptest::prelude::*; use uninum::{Number, num}; @@ -806,6 +808,28 @@ fn test_hash_special_float_values() { ); } +#[cfg(feature = "decimal")] +#[test] +fn test_decimal_hash_distinguishes_close_values() { + use rust_decimal::Decimal; + + // Two decimals that differ beyond f64 precision must still hash distinctly. + let a = Number::from(Decimal::from_str("0.1000000000000000000000000000").unwrap()); + let b = Number::from(Decimal::from_str("0.1000000000000000000000000001").unwrap()); + + let mut set = HashSet::new(); + set.insert(a.clone()); + set.insert(b.clone()); + + assert_eq!( + set.len(), + 2, + "HashSet should retain both distinct decimal values" + ); + assert!(set.contains(&a)); + assert!(set.contains(&b)); +} + #[test] fn test_hash_zero_canonical_handling() { // Test that zero values are handled correctly in canonical form diff --git a/tests/traits/ordering.rs b/tests/traits/ordering.rs index 5565b66..e9c1f79 100644 --- a/tests/traits/ordering.rs +++ b/tests/traits/ordering.rs @@ -8,6 +8,8 @@ //! - Consistency with equality: if a == b, then a <= b and b <= a //! - Special value ordering: NaN, infinity behavior +#[cfg(feature = "decimal")] +use std::str::FromStr; use uninum::{Number, num}; /// Generate a comprehensive set of test numbers for ordering tests @@ -352,6 +354,28 @@ fn test_zero_ordering_consistency() { } } +#[cfg(feature = "decimal")] +#[test] +fn test_decimal_ordering_respects_exact_value() { + use rust_decimal::Decimal; + use std::cmp::Ordering; + + // These decimals differ at 1e-28; cmp must see the difference and Eq must + // not collapse via f64. + let a = Number::from(Decimal::from_str("0.1000000000000000000000000000").unwrap()); + let b = Number::from(Decimal::from_str("0.1000000000000000000000000001").unwrap()); + + assert_ne!( + a, b, + "Decimal equality should distinguish nearby fractional values" + ); + assert_ne!( + a.cmp(&b), + Ordering::Equal, + "Decimal ordering should distinguish nearby fractional values" + ); +} + #[test] fn test_large_number_ordering() { // Test ordering with large numbers that might have precision issues