Skip to content

Commit f181675

Browse files
committed
audit regressions, tests, fixes
1 parent 06cde9a commit f181675

9 files changed

Lines changed: 167 additions & 51 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ control. `hyperreal` uses several small performance strategies together:
167167

168168
## Current Status
169169

170-
Version `0.12.0` is active and benchmark-driven. Current implementation work includes:
170+
Version `0.13.0` is active and benchmark-driven. Current implementation work includes:
171171

172172
- exact rational and dyadic fast paths;
173173
- dedicated constructors and cached constants for common zeros, ones, `pi`, `tau`, `e`,

benches/dispatch_trace.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -542,8 +542,8 @@ fn collect_rows(filters: &[String]) -> BTreeMap<String, hyperreal::dispatch_trac
542542
|| {
543543
let minus_pi = Computable::pi().negate();
544544
let pi = Computable::pi();
545-
black_box(minus_pi.compare_to(&pi));
546-
black_box(pi.compare_to(&minus_pi));
545+
black_box(minus_pi.try_compare_to(&pi));
546+
black_box(pi.try_compare_to(&minus_pi));
547547
},
548548
);
549549
trace_row(
@@ -557,9 +557,9 @@ fn collect_rows(filters: &[String]) -> BTreeMap<String, hyperreal::dispatch_trac
557557
.multiply(Computable::rational(Rational::from_bigint(
558558
BigInt::from(1_u8) << 200,
559559
)));
560-
black_box(huge.compare_to(&base));
561-
black_box(base.compare_to(&huge));
562-
black_box(huge.negate().compare_to(&base.negate()));
560+
black_box(huge.try_compare_to(&base));
561+
black_box(base.try_compare_to(&huge));
562+
black_box(huge.negate().try_compare_to(&base.negate()));
563563
},
564564
);
565565
trace_row(
@@ -569,9 +569,9 @@ fn collect_rows(filters: &[String]) -> BTreeMap<String, hyperreal::dispatch_trac
569569
|| {
570570
let lhs = Computable::rational(rational(3, 7));
571571
let rhs = Computable::rational(rational(2, 7));
572-
black_box(rhs.compare_to(&lhs));
573-
black_box(lhs.compare_to(&rhs));
574-
black_box(lhs.compare_to(&lhs));
572+
black_box(rhs.try_compare_to(&lhs));
573+
black_box(lhs.try_compare_to(&rhs));
574+
black_box(lhs.try_compare_to(&lhs));
575575
},
576576
);
577577
trace_row(

benches/numerical_micro.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -828,7 +828,7 @@ fn bench_computable_compare(c: &mut Criterion) {
828828
let minus_pi = Computable::pi().negate();
829829
let pi = Computable::pi();
830830
group.bench_function("compare_to_opposite_sign", |b| {
831-
b.iter(|| black_box(minus_pi.compare_to(&pi)))
831+
b.iter(|| black_box(minus_pi.try_compare_to(&pi)))
832832
});
833833

834834
let base = Computable::pi();
@@ -839,7 +839,7 @@ fn bench_computable_compare(c: &mut Criterion) {
839839
BigInt::from(1_u8) << 200,
840840
)));
841841
group.bench_function("compare_to_exact_msd_gap", |b| {
842-
b.iter(|| black_box(huge.compare_to(&base)))
842+
b.iter(|| black_box(huge.try_compare_to(&base)))
843843
});
844844

845845
let left = Computable::rational(Rational::fraction(-7, 8).unwrap());

src/computable/node.rs

Lines changed: 48 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4437,34 +4437,43 @@ impl Computable {
44374437
}
44384438
}
44394439

4440-
/// Do not call this function if `self` and `other` may be the same.
4441-
pub fn compare_to(&self, other: &Self) -> Ordering {
4440+
/// Try to compare two computable values exactly.
4441+
///
4442+
/// Returns `None` when bounded refinement cannot prove an ordering. This is
4443+
/// the public comparison API for callers that may be comparing equal or
4444+
/// semantically equivalent values.
4445+
pub fn try_compare_to(&self, other: &Self) -> Option<Ordering> {
4446+
if Self::internal_structural_eq(self, other) {
4447+
return Some(Ordering::Equal);
4448+
}
4449+
44424450
// Keep exact leaf comparisons allocation-free for the hot path where both
44434451
// operands are already exact. This avoids creating temporary rationals
44444452
// on every comparator call.
44454453
if let Some(order) = self.exact_rational_leaf_cmp(other) {
44464454
crate::trace_dispatch!("computable", "compare_to", "exact-rational");
4447-
return order;
4455+
return Some(order);
44484456
}
44494457

44504458
if let (Some(left), Some(right)) = (self.exact_rational(), other.exact_rational()) {
44514459
// Exact rationals compare directly; escalating to approximate comparison here is
44524460
// both slower and can burn cache precision unnecessarily.
44534461
crate::trace_dispatch!("computable", "compare_to", "exact-rational");
4454-
return left
4455-
.partial_cmp(&right)
4456-
.expect("exact rationals should be comparable");
4462+
return Some(
4463+
left.partial_cmp(&right)
4464+
.expect("exact rationals should be comparable"),
4465+
);
44574466
}
44584467

44594468
if let (Some(left), Some(right)) = (self.exact_sign(), other.exact_sign()) {
44604469
match (left, right) {
44614470
(Sign::Minus, Sign::Plus | Sign::NoSign) | (Sign::NoSign, Sign::Plus) => {
44624471
crate::trace_dispatch!("computable", "compare_to", "exact-sign-opposite");
4463-
return Ordering::Less;
4472+
return Some(Ordering::Less);
44644473
}
44654474
(Sign::Plus, Sign::Minus | Sign::NoSign) | (Sign::NoSign, Sign::Minus) => {
44664475
crate::trace_dispatch!("computable", "compare_to", "exact-sign-opposite");
4467-
return Ordering::Greater;
4476+
return Some(Ordering::Greater);
44684477
}
44694478
_ => {}
44704479
}
@@ -4480,11 +4489,11 @@ impl Computable {
44804489
// Same-sign values with different most-significant digits have a known
44814490
// order without evaluating either value to a requested precision.
44824491
crate::trace_dispatch!("computable", "compare_to", "exact-sign-msd-gap");
4483-
return match left {
4492+
return Some(match left {
44844493
Sign::Plus => left_msd.cmp(&right_msd),
44854494
Sign::Minus => right_msd.cmp(&left_msd),
44864495
Sign::NoSign => unreachable!(),
4487-
};
4496+
});
44884497
}
44894498
}
44904499

@@ -4496,13 +4505,13 @@ impl Computable {
44964505
match (left, right) {
44974506
(Sign::Minus, Sign::Plus) | (Sign::NoSign, Sign::Plus) => {
44984507
crate::trace_dispatch!("computable", "compare_to", "cheap-bound-opposite-sign");
4499-
return Ordering::Less;
4508+
return Some(Ordering::Less);
45004509
}
45014510
(Sign::Plus, Sign::Minus) | (Sign::Plus, Sign::NoSign) => {
45024511
crate::trace_dispatch!("computable", "compare_to", "cheap-bound-opposite-sign");
4503-
return Ordering::Greater;
4512+
return Some(Ordering::Greater);
45044513
}
4505-
(Sign::NoSign, Sign::NoSign) => return Ordering::Equal,
4514+
(Sign::NoSign, Sign::NoSign) => return Some(Ordering::Equal),
45064515
_ => {}
45074516
}
45084517
if left == right
@@ -4513,23 +4522,23 @@ impl Computable {
45134522
// Same-sign structural bounds can decide exact ordering
45144523
// before entering tolerance refinement.
45154524
crate::trace_dispatch!("computable", "compare_to", "cheap-bound-msd-gap");
4516-
return match left {
4525+
return Some(match left {
45174526
Sign::Plus => left_msd.cmp(&right_msd),
45184527
Sign::Minus => right_msd.cmp(&left_msd),
45194528
Sign::NoSign => Ordering::Equal,
4520-
};
4529+
});
45214530
}
45224531
}
45234532
crate::trace_dispatch!("computable", "compare_to", "approx-refinement");
45244533
let mut tolerance = -20;
45254534
while tolerance > Precision::MIN {
45264535
let order = self.compare_absolute(other, tolerance);
45274536
if order != Ordering::Equal {
4528-
return order;
4537+
return Some(order);
45294538
}
45304539
tolerance *= 2;
45314540
}
4532-
panic!("Apparently called Computable::compare_to on equal values");
4541+
None
45334542
}
45344543

45354544
/// Compare two values to a specified tolerance (more negative numbers are more precise).
@@ -4813,9 +4822,9 @@ mod tests {
48134822
let five = Computable::integer(five.clone());
48144823
let four = Computable::integer(four.clone());
48154824

4816-
assert_eq!(six.compare_to(&five), Ordering::Greater);
4817-
assert_eq!(five.compare_to(&six), Ordering::Less);
4818-
assert_eq!(four.compare_to(&six), Ordering::Less);
4825+
assert_eq!(six.try_compare_to(&five), Some(Ordering::Greater));
4826+
assert_eq!(five.try_compare_to(&six), Some(Ordering::Less));
4827+
assert_eq!(four.try_compare_to(&six), Some(Ordering::Less));
48194828
}
48204829

48214830
#[test]
@@ -5138,11 +5147,21 @@ mod tests {
51385147
fn compare_to_uses_exact_sign_and_rational_shortcuts() {
51395148
let minus_pi = Computable::pi().negate();
51405149
let pi = Computable::pi();
5141-
assert_eq!(minus_pi.compare_to(&pi), Ordering::Less);
5150+
assert_eq!(minus_pi.try_compare_to(&pi), Some(Ordering::Less));
51425151

51435152
let left = Computable::rational(Rational::fraction(7, 8).unwrap());
51445153
let right = Computable::rational(Rational::fraction(9, 10).unwrap());
5145-
assert_eq!(left.compare_to(&right), Ordering::Less);
5154+
assert_eq!(left.try_compare_to(&right), Some(Ordering::Less));
5155+
}
5156+
5157+
#[test]
5158+
fn try_compare_to_handles_identical_symbolic_values() {
5159+
let pi = Computable::pi();
5160+
assert_eq!(pi.try_compare_to(&pi), Some(Ordering::Equal));
5161+
5162+
let left = Computable::rational(Rational::fraction(3, 7).unwrap());
5163+
let right = Computable::rational(Rational::fraction(3, 7).unwrap());
5164+
assert_eq!(left.try_compare_to(&right), Some(Ordering::Equal));
51465165
}
51475166

51485167
#[test]
@@ -5154,13 +5173,16 @@ mod tests {
51545173
.multiply(Computable::rational(Rational::from_bigint(
51555174
BigInt::from(1_u8) << 200,
51565175
)));
5157-
assert_eq!(huge.compare_to(&base), Ordering::Greater);
5158-
assert_eq!(base.compare_to(&huge), Ordering::Less);
5176+
assert_eq!(huge.try_compare_to(&base), Some(Ordering::Greater));
5177+
assert_eq!(base.try_compare_to(&huge), Some(Ordering::Less));
51595178

51605179
let minus_base = base.negate();
51615180
let minus_huge = huge.negate();
5162-
assert_eq!(minus_huge.compare_to(&minus_base), Ordering::Less);
5163-
assert_eq!(minus_base.compare_to(&minus_huge), Ordering::Greater);
5181+
assert_eq!(minus_huge.try_compare_to(&minus_base), Some(Ordering::Less));
5182+
assert_eq!(
5183+
minus_base.try_compare_to(&minus_huge),
5184+
Some(Ordering::Greater)
5185+
);
51645186
}
51655187

51665188
#[test]

src/rational/arithmetic.rs

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use crate::structural::{RationalFacts, RationalStorageClass};
33
use num::bigint::Sign::{self, *};
44
use num::{BigInt, BigUint, ToPrimitive};
55
use num::{One, Zero};
6-
use serde::{Deserialize, Serialize};
6+
use serde::{Deserialize, Deserializer, Serialize};
77
use std::cmp::Ordering;
88
use std::sync::LazyLock;
99

@@ -49,13 +49,35 @@ use std::sync::LazyLock;
4949
/// assert_eq!(four, Rational::new(4));
5050
/// ```
5151
52-
#[derive(Clone, Debug, Serialize, Deserialize)]
52+
#[derive(Clone, Debug, Serialize)]
5353
pub struct Rational {
5454
sign: Sign,
5555
numerator: BigUint,
5656
denominator: BigUint,
5757
}
5858

59+
impl<'de> Deserialize<'de> for Rational {
60+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
61+
where
62+
D: Deserializer<'de>,
63+
{
64+
#[derive(Deserialize)]
65+
struct RationalWire {
66+
sign: Sign,
67+
numerator: BigUint,
68+
denominator: BigUint,
69+
}
70+
71+
let wire = RationalWire::deserialize(deserializer)?;
72+
if wire.denominator.is_zero() {
73+
return Err(serde::de::Error::custom(
74+
"Rational denominator must be nonzero",
75+
));
76+
}
77+
Ok(Self::from_fraction_parts(wire.sign, wire.numerator, wire.denominator).reduce())
78+
}
79+
}
80+
5981
static ONE: LazyLock<BigUint> = LazyLock::new(BigUint::one);
6082
// Small positive constants use their narrow primitive source type; this keeps
6183
// construction direct and avoids the older `ToBigUint` conversion shim.
@@ -1613,11 +1635,11 @@ impl std::str::FromStr for Rational {
16131635
if numerator.is_zero() {
16141636
sign = NoSign;
16151637
}
1616-
Ok(Self {
1617-
sign,
1618-
numerator,
1619-
denominator: BigUint::parse_bytes(d.as_bytes(), 10).ok_or(Problem::BadFraction)?,
1620-
})
1638+
let denominator = BigUint::parse_bytes(d.as_bytes(), 10).ok_or(Problem::BadFraction)?;
1639+
if denominator.is_zero() {
1640+
return Err(Problem::DivideByZero);
1641+
}
1642+
Ok(Self::from_fraction_parts(sign, numerator, denominator).reduce())
16211643
} else if let Some((i, d)) = s.split_once('.') {
16221644
let numerator = BigUint::parse_bytes(i.as_bytes(), 10).ok_or(Problem::BadDecimal)?;
16231645
let whole = if numerator.is_zero() {
@@ -1963,6 +1985,27 @@ mod tests {
19631985
assert_eq!(answer, expected);
19641986
}
19651987

1988+
#[test]
1989+
fn parse_fraction_rejects_zero_denominator_and_reduces() {
1990+
assert_eq!("1/0".parse::<Rational>(), Err(Problem::DivideByZero));
1991+
assert_eq!("0/0".parse::<Rational>(), Err(Problem::DivideByZero));
1992+
1993+
let reduced: Rational = "9/18".parse().unwrap();
1994+
assert_eq!(reduced, Rational::fraction(1, 2).unwrap());
1995+
assert_eq!(format!("{reduced}"), "1/2");
1996+
}
1997+
1998+
#[test]
1999+
fn serde_rejects_invalid_or_uncanonical_rational_state() {
2000+
let bad = r#"{"sign":1,"numerator":[1],"denominator":[]}"#;
2001+
assert!(serde_json::from_str::<Rational>(bad).is_err());
2002+
2003+
let unreduced = r#"{"sign":1,"numerator":[9],"denominator":[18]}"#;
2004+
let decoded: Rational = serde_json::from_str(unreduced).unwrap();
2005+
assert_eq!(decoded, Rational::fraction(1, 2).unwrap());
2006+
assert_eq!(format!("{decoded}"), "1/2");
2007+
}
2008+
19662009
#[test]
19672010
fn square_reduced() {
19682011
let thirty_two = Rational::new(32);

src/real/tests.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1978,8 +1978,8 @@ mod tests {
19781978
fn computable_atan2_axes() {
19791979
use crate::Computable;
19801980
use num::Zero;
1981-
// compare_to(&equal) on Computable can loop forever (kernel docs warn
1982-
// about this), so axis cases are validated through approx values.
1981+
// Axis cases are validated through approximations because these values
1982+
// exercise the symbolic zero branches in the computable kernel.
19831983
let zero_plus = Computable::zero().atan2(Computable::one());
19841984
assert!(zero_plus.approx(-30).is_zero());
19851985
let zero_minus = Computable::zero().atan2(Computable::one().negate());

src/serde.rs

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::str::FromStr;
33
use crate::{Problem, Rational, real::Real};
44
use ciborium::{Value, de, ser};
55
use num::BigInt;
6+
use num::bigint::Sign as BigIntSign;
67

78
impl Real {
89
/// Serializes the Real to a JSON string.
@@ -68,11 +69,19 @@ impl TryFrom<&Value> for Real {
6869
}
6970
}
7071
// If we receive an array of length 2, we attempt to interpret it as the numerator and denominator of a Rational.
71-
let numerator = as_big_int(&x[0])?;
72+
let mut numerator = as_big_int(&x[0])?;
7273
let denominator = as_big_int(&x[1])?;
73-
Ok(Real::new(
74-
Rational::from_bigint(numerator) / Rational::from_bigint(denominator),
75-
))
74+
let (denominator_sign, denominator_magnitude) = denominator.into_parts();
75+
if denominator_sign == BigIntSign::NoSign {
76+
return Err(Problem::DivideByZero);
77+
}
78+
if denominator_sign == BigIntSign::Minus {
79+
numerator = -numerator;
80+
}
81+
Ok(Real::new(Rational::from_bigint_fraction(
82+
numerator,
83+
denominator_magnitude,
84+
)?))
7685
}
7786
_ => {
7887
// In this branch, we should be whatever type ciborium decided to use for `Real`.
@@ -262,4 +271,19 @@ mod tests {
262271
let y: Real = (&value).try_into().unwrap();
263272
assert_eq!(x, y);
264273
}
274+
275+
#[test]
276+
fn cbor_rational_pair_rejects_zero_denominator() {
277+
let value = Value::Array(vec![Value::Integer(1.into()), Value::Integer(0.into())]);
278+
assert_eq!(Real::try_from(&value), Err(Problem::DivideByZero));
279+
}
280+
281+
#[test]
282+
fn cbor_rational_pair_normalizes_negative_denominator() {
283+
let value = Value::Array(vec![Value::Integer(1.into()), Value::Integer((-2).into())]);
284+
assert_eq!(
285+
Real::try_from(&value).unwrap(),
286+
Real::new(Rational::fraction(-1, 2).unwrap())
287+
);
288+
}
265289
}

0 commit comments

Comments
 (0)