Skip to main content

symplex/base/
numeric.rs

1//! Exact conversions between machine floats and rationals.
2//!
3//! Every finite `f64` is a dyadic rational `m · 2^e`, so it can be
4//! converted to a [`Ratio<BigInt>`] *exactly* — no rounding, no chosen
5//! denominator.  That is the right default for a library whose core
6//! promise is exact arithmetic: `0.1_f64` really is
7//! `3602879701896397/36028797018963968`, and pretending otherwise hides
8//! error.
9//!
10//! When the caller *wants* the "nice" rational a human meant
11//! (`0.1 → 1/10`), use [`f64_to_ratio_approx`], which returns the best
12//! rational approximation with a bounded denominator (Stern–Brocot /
13//! continued-fraction convergents), or the higher-level
14//! `Context::from_f64_approx`.
15//!
16//! These two functions replace the several ad-hoc `(x * 10^k).round()`
17//! helpers that used to be scattered through the crate, each with a
18//! different scale and each silently saturating on large inputs.
19
20use num_bigint::{BigInt, Sign};
21use num_rational::Ratio;
22use num_traits::{One, Signed, ToPrimitive, Zero};
23
24/// Convert a finite `f64` to the exact rational it represents.
25///
26/// Returns `None` for NaN and ±∞.  Negative zero maps to `0`.
27///
28/// # Examples
29///
30/// ```
31/// use symplex::base::numeric::f64_to_ratio_exact;
32/// use num_bigint::BigInt;
33/// use num_rational::Ratio;
34///
35/// assert_eq!(f64_to_ratio_exact(0.5), Some(Ratio::new(BigInt::from(1), BigInt::from(2))));
36/// assert_eq!(f64_to_ratio_exact(-3.0), Some(Ratio::from_integer(BigInt::from(-3))));
37/// // 0.1 is *not* 1/10 in binary floating point:
38/// let tenth = f64_to_ratio_exact(0.1).unwrap();
39/// assert_eq!(*tenth.denom(), BigInt::from(36028797018963968_u64));
40/// assert!(f64_to_ratio_exact(f64::NAN).is_none());
41/// assert!(f64_to_ratio_exact(f64::INFINITY).is_none());
42/// ```
43#[must_use]
44pub fn f64_to_ratio_exact(x: f64) -> Option<Ratio<BigInt>> {
45    if !x.is_finite() {
46        return None;
47    }
48    if x == 0.0 {
49        return Some(Ratio::zero());
50    }
51
52    let bits = x.to_bits();
53    let sign = if bits >> 63 == 0 {
54        Sign::Plus
55    } else {
56        Sign::Minus
57    };
58    let exponent = ((bits >> 52) & 0x7ff) as i64;
59    let fraction = bits & 0x000f_ffff_ffff_ffff;
60
61    // Subnormals have an implicit leading 0 and a fixed exponent of -1074;
62    // normals have an implicit leading 1 and exponent (e - 1075) after
63    // folding the 52-bit fraction into the mantissa.
64    let (mantissa, exp2) = if exponent == 0 {
65        (fraction, -1074_i64)
66    } else {
67        (fraction | (1_u64 << 52), exponent - 1075)
68    };
69
70    let mantissa = BigInt::from_biguint(sign, mantissa.into());
71    let ratio = if exp2 >= 0 {
72        Ratio::from_integer(mantissa << (exp2 as usize))
73    } else {
74        Ratio::new(mantissa, BigInt::one() << ((-exp2) as usize))
75    };
76    Some(ratio)
77}
78
79/// Best rational approximation to `x` with denominator at most `max_denom`.
80///
81/// Walks the continued-fraction convergents (and semiconvergents) of the
82/// exact value of `x`, so the result is the *closest* rational with a
83/// denominator not exceeding `max_denom` — this is what turns `0.1` into
84/// `1/10`, `0.3333333333333333` into `1/3`, and `3.14159` into `355/113`
85/// (for `max_denom = 1000`).
86///
87/// Returns `None` for NaN / ±∞ or `max_denom == 0`.
88///
89/// # Examples
90///
91/// ```
92/// use symplex::base::numeric::f64_to_ratio_approx;
93/// use num_bigint::BigInt;
94/// use num_rational::Ratio;
95///
96/// let r = |p: i64, q: i64| Ratio::new(BigInt::from(p), BigInt::from(q));
97/// assert_eq!(f64_to_ratio_approx(0.1, 1_000_000), Some(r(1, 10)));
98/// assert_eq!(f64_to_ratio_approx(1.0 / 3.0, 1_000_000), Some(r(1, 3)));
99/// assert_eq!(f64_to_ratio_approx(0.3, 1_000_000), Some(r(3, 10)));
100/// assert_eq!(f64_to_ratio_approx(std::f64::consts::PI, 1000), Some(r(355, 113)));
101/// assert_eq!(f64_to_ratio_approx(-2.5, 10), Some(r(-5, 2)));
102/// assert_eq!(f64_to_ratio_approx(7.0, 1), Some(r(7, 1)));
103/// ```
104#[must_use]
105pub fn f64_to_ratio_approx(x: f64, max_denom: u64) -> Option<Ratio<BigInt>> {
106    if max_denom == 0 {
107        return None;
108    }
109    let exact = f64_to_ratio_exact(x)?;
110    Some(best_rational_approx(&exact, &BigInt::from(max_denom)))
111}
112
113/// Best rational approximation to `target` with denominator `≤ max_denom`,
114/// via continued-fraction convergents and the final semiconvergent.
115///
116/// `max_denom` must be `≥ 1`.
117#[must_use]
118pub fn best_rational_approx(target: &Ratio<BigInt>, max_denom: &BigInt) -> Ratio<BigInt> {
119    debug_assert!(max_denom.is_positive());
120    if target.denom() <= max_denom {
121        return target.clone();
122    }
123
124    let negative = target.is_negative();
125    let t = target.abs();
126
127    // Standard convergent recurrence:  h_n = a_n h_{n-1} + h_{n-2}, same for k.
128    let (mut h_prev, mut h) = (BigInt::one(), BigInt::zero()); // h_{-1}=1, h_{-2}=0
129    let (mut k_prev, mut k) = (BigInt::zero(), BigInt::one()); // k_{-1}=0, k_{-2}=1
130    let mut rem = t.clone();
131
132    loop {
133        let a = rem.floor().to_integer();
134        let h_next = &a * &h_prev + &h;
135        let k_next = &a * &k_prev + &k;
136
137        if &k_next > max_denom {
138            // The next convergent h_n/k_n overshoots the denominator bound.
139            // Here h_prev/k_prev = h_{n-1}/k_{n-1} (last good convergent) and
140            // h/k = h_{n-2}/k_{n-2}.  The semiconvergents are
141            //     (h_{n-2} + m·h_{n-1}) / (k_{n-2} + m·k_{n-1}),  0 < m < a_n,
142            // and the largest admissible m gives the closest one.  Pick
143            // whichever of that and the last convergent is nearer.
144            let m = (max_denom - &k) / &k_prev;
145            let candidate = if m.is_zero() {
146                None
147            } else {
148                Some(Ratio::new(&h + &m * &h_prev, &k + &m * &k_prev))
149            };
150            let conv = Ratio::new(h_prev.clone(), k_prev.clone());
151            let best = match candidate {
152                Some(semi) if (&semi - &t).abs() < (&conv - &t).abs() => semi,
153                _ => conv,
154            };
155            return if negative { -best } else { best };
156        }
157
158        h = std::mem::replace(&mut h_prev, h_next);
159        k = std::mem::replace(&mut k_prev, k_next);
160
161        let frac = &rem - Ratio::from_integer(a);
162        if frac.is_zero() {
163            // Exact: the convergent is the target itself.
164            let exact = Ratio::new(h_prev.clone(), k_prev.clone());
165            return if negative { -exact } else { exact };
166        }
167        rem = frac.recip();
168    }
169}
170
171/// Convert a rational to the nearest `f64`, returning `None` if it does
172/// not fit (overflow to ±∞ is reported as `None`).
173#[must_use]
174pub fn ratio_to_f64(r: &Ratio<BigInt>) -> Option<f64> {
175    // `to_f64` on Ratio<BigInt> performs a correctly-rounded division when
176    // both parts fit in f64 range; otherwise fall back to scaling.
177    if let Some(v) = r.to_f64()
178        && v.is_finite()
179    {
180        return Some(v);
181    }
182    // Scale down by a common power of two until both fit.
183    let shift = r.numer().bits().max(r.denom().bits()).saturating_sub(1000) as usize;
184    let n = r.numer() >> shift;
185    let d = r.denom() >> shift;
186    if d.is_zero() {
187        return None;
188    }
189    let v = n.to_f64()? / d.to_f64()?;
190    v.is_finite().then_some(v)
191}
192
193/// Integer square root helper for tests and callers that need exactness.
194#[must_use]
195pub fn is_perfect_square(n: &BigInt) -> bool {
196    if n.is_negative() {
197        return false;
198    }
199    let r = n.sqrt();
200    &r * &r == *n
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    fn r(p: i64, q: i64) -> Ratio<BigInt> {
208        Ratio::new(BigInt::from(p), BigInt::from(q))
209    }
210
211    #[test]
212    fn exact_round_trips_through_f64() {
213        for &x in &[
214            0.0,
215            -0.0,
216            1.0,
217            -1.0,
218            0.5,
219            0.1,
220            1.0 / 3.0,
221            1e300,
222            -1e-300,
223            f64::MIN_POSITIVE,
224            f64::MAX,
225            5e-324, // smallest subnormal
226            123_456_789.987_654_3,
227        ] {
228            let ratio = f64_to_ratio_exact(x).unwrap();
229            let back = ratio_to_f64(&ratio).unwrap();
230            assert_eq!(back.to_bits(), (x + 0.0).to_bits(), "x = {x:e}");
231        }
232    }
233
234    #[test]
235    fn exact_rejects_non_finite() {
236        assert!(f64_to_ratio_exact(f64::NAN).is_none());
237        assert!(f64_to_ratio_exact(f64::INFINITY).is_none());
238        assert!(f64_to_ratio_exact(f64::NEG_INFINITY).is_none());
239    }
240
241    #[test]
242    fn exact_known_values() {
243        assert_eq!(f64_to_ratio_exact(0.75), Some(r(3, 4)));
244        assert_eq!(f64_to_ratio_exact(-1024.0), Some(r(-1024, 1)));
245        assert_eq!(f64_to_ratio_exact(1.5e3), Some(r(1500, 1)));
246    }
247
248    #[test]
249    fn approx_recovers_human_decimals() {
250        let cases = [
251            (0.1, 1, 10),
252            (0.2, 1, 5),
253            (0.3, 3, 10),
254            (0.25, 1, 4),
255            (0.125, 1, 8),
256            (2.0 / 3.0, 2, 3),
257            (0.142857142857, 1, 7),
258        ];
259        for &(x, p, q) in &cases {
260            assert_eq!(f64_to_ratio_approx(x, 1_000_000), Some(r(p, q)), "x = {x}");
261        }
262        // √2 has no small rational form.  The f64 `SQRT_2` lies just below
263        // the true √2, so among denominators ≤ 100 the closest rational to
264        // *that float* is 140/99 (the next convergent 99/70 is above it).
265        let best = f64_to_ratio_approx(std::f64::consts::SQRT_2, 100).unwrap();
266        assert!(*best.denom() <= BigInt::from(100));
267        let exact = f64_to_ratio_exact(std::f64::consts::SQRT_2).unwrap();
268        for &(p, q) in &[(99_i64, 70_i64), (141, 100), (17, 12), (7, 5)] {
269            assert!(
270                (&best - &exact).abs() <= (&r(p, q) - &exact).abs(),
271                "{best} is worse than {p}/{q}"
272            );
273        }
274    }
275
276    #[test]
277    fn approx_respects_denominator_bound() {
278        for &md in &[1u64, 2, 3, 7, 10, 100, 1000, 1_000_000] {
279            for &x in &[
280                0.1,
281                0.7,
282                std::f64::consts::PI,
283                -std::f64::consts::E,
284                12345.6789,
285            ] {
286                let a = f64_to_ratio_approx(x, md).unwrap();
287                assert!(*a.denom() <= BigInt::from(md), "x={x}, md={md}, got {a}");
288                // Must be at least as good as naive rounding with that denominator.
289                let naive = Ratio::new(
290                    BigInt::from((x * md as f64).round() as i64),
291                    BigInt::from(md),
292                );
293                let exact = f64_to_ratio_exact(x).unwrap();
294                assert!(
295                    (&a - &exact).abs() <= (&naive - &exact).abs(),
296                    "x={x}, md={md}: {a} worse than naive {naive}"
297                );
298            }
299        }
300    }
301
302    #[test]
303    fn approx_negative_and_integer() {
304        assert_eq!(f64_to_ratio_approx(-0.5, 10), Some(r(-1, 2)));
305        assert_eq!(f64_to_ratio_approx(-7.0, 10), Some(r(-7, 1)));
306        assert_eq!(f64_to_ratio_approx(0.0, 10), Some(r(0, 1)));
307        assert!(f64_to_ratio_approx(1.0, 0).is_none());
308    }
309
310    #[test]
311    fn best_rational_exact_when_within_bound() {
312        let t = r(22, 7);
313        assert_eq!(best_rational_approx(&t, &BigInt::from(7)), t);
314        assert_eq!(best_rational_approx(&t, &BigInt::from(1000)), t);
315    }
316
317    #[test]
318    fn perfect_square() {
319        assert!(is_perfect_square(&BigInt::from(0)));
320        assert!(is_perfect_square(&BigInt::from(144)));
321        assert!(!is_perfect_square(&BigInt::from(145)));
322        assert!(!is_perfect_square(&BigInt::from(-4)));
323    }
324}