Skip to main content

oxinum_rational/
convert.rs

1//! Conversions between [`RBig`] and primitive floating-point / string forms.
2//!
3//! This module wraps `dashu`'s already-exact bit-level decoding of `f64` /
4//! `f32` (via [`TryFrom<f64>`] / [`TryFrom<f32>`]) plus the `to_f64()` and
5//! `TryFrom<RBig> for f64` paths and exposes them through the
6//! [`OxiNumError`] result type expected by the rest of the OxiNum
7//! ecosystem.  It also adds a `parse_mixed` free function and a
8//! `MixedNumber` newtype that implements `FromStr` for the human-friendly
9//! mixed-number format (e.g. `"1 3/4"`, `"-2 1/3"`).
10//!
11//! The float decoding is exact (no rounding) because IEEE 754 `f32` /
12//! `f64` are dyadic rationals; representing them as `RBig` is therefore
13//! lossless.  See `from_f64` / `from_f32` below.
14//!
15//! All routines are `#![forbid(unsafe_code)]`; the bit-level work is done
16//! through safe primitives such as `f64::to_bits()` and `dashu_base`'s
17//! `decode()`.
18
19use core::str::FromStr;
20
21use dashu_base::ConversionError;
22
23use crate::{IBig, RBig, UBig};
24use oxinum_core::{OxiNumError, OxiNumResult};
25
26// ---------------------------------------------------------------------------
27// Float -> Rational (exact)
28// ---------------------------------------------------------------------------
29
30/// Convert an `f64` to an exact rational.
31///
32/// IEEE-754 `f64` values are dyadic rationals (`mantissa * 2^exp` for some
33/// signed `exp`), so finite floats round-trip exactly.
34///
35/// # Errors
36///
37/// Returns [`OxiNumError::Parse`] if `x` is NaN or infinite.
38///
39/// # Examples
40///
41/// ```
42/// use oxinum_rational::{from_f64, RBig, IBig, UBig};
43/// // 0.5 exactly = 1/2
44/// let r = from_f64(0.5).unwrap();
45/// assert_eq!(r, RBig::from_parts(IBig::from(1), UBig::from(2u32)));
46/// // 0.25 exactly = 1/4
47/// assert_eq!(from_f64(0.25).unwrap(),
48///            RBig::from_parts(IBig::from(1), UBig::from(4u32)));
49/// // NaN errors
50/// assert!(from_f64(f64::NAN).is_err());
51/// assert!(from_f64(f64::INFINITY).is_err());
52/// ```
53pub fn from_f64(x: f64) -> OxiNumResult<RBig> {
54    RBig::try_from(x).map_err(|_| OxiNumError::Parse(format!("non-finite f64: {x}").into()))
55}
56
57/// Convert an `f32` to an exact rational.
58///
59/// IEEE-754 `f32` values are dyadic rationals, so finite floats round-trip
60/// exactly.
61///
62/// # Errors
63///
64/// Returns [`OxiNumError::Parse`] if `x` is NaN or infinite.
65///
66/// # Examples
67///
68/// ```
69/// use oxinum_rational::{from_f32, RBig, IBig, UBig};
70/// let r = from_f32(0.5).unwrap();
71/// assert_eq!(r, RBig::from_parts(IBig::from(1), UBig::from(2u32)));
72/// assert!(from_f32(f32::NAN).is_err());
73/// ```
74pub fn from_f32(x: f32) -> OxiNumResult<RBig> {
75    RBig::try_from(x).map_err(|_| OxiNumError::Parse(format!("non-finite f32: {x}").into()))
76}
77
78// ---------------------------------------------------------------------------
79// Rational -> Float
80// ---------------------------------------------------------------------------
81
82/// Convert a rational to the nearest `f64` (round-to-nearest, ties-to-even).
83///
84/// Returns `f64::INFINITY` / `f64::NEG_INFINITY` if the magnitude is
85/// outside the representable range, and `0.0` for underflow.  Use
86/// [`to_f64_exact`] if you need to detect precision loss.
87///
88/// # Examples
89///
90/// ```
91/// use oxinum_rational::{to_f64, RBig, IBig, UBig};
92/// let half = RBig::from_parts(IBig::from(1), UBig::from(2u32));
93/// assert_eq!(to_f64(&half), 0.5);
94/// let third = RBig::from_parts(IBig::from(1), UBig::from(3u32));
95/// assert!((to_f64(&third) - 1.0_f64 / 3.0).abs() < 1e-15);
96/// ```
97pub fn to_f64(x: &RBig) -> f64 {
98    x.to_f64().value()
99}
100
101/// Convert a rational to an `f64`, requiring exact representability.
102///
103/// # Errors
104///
105/// - [`OxiNumError::Overflow`] if the magnitude is outside the
106///   representable `f64` range.
107/// - [`OxiNumError::Precision`] if the value cannot be represented
108///   without rounding (e.g. `1/3`).
109///
110/// # Examples
111///
112/// ```
113/// use oxinum_rational::{to_f64_exact, RBig, IBig, UBig};
114/// // 1/2 is exact
115/// let half = RBig::from_parts(IBig::from(1), UBig::from(2u32));
116/// assert_eq!(to_f64_exact(&half).unwrap(), 0.5);
117/// // 1/3 is not representable
118/// let third = RBig::from_parts(IBig::from(1), UBig::from(3u32));
119/// assert!(to_f64_exact(&third).is_err());
120/// ```
121pub fn to_f64_exact(x: &RBig) -> OxiNumResult<f64> {
122    match f64::try_from(x.clone()) {
123        Ok(v) => Ok(v),
124        Err(ConversionError::OutOfBounds) => Err(OxiNumError::Overflow(
125            format!("rational {x} exceeds f64 range").into(),
126        )),
127        Err(ConversionError::LossOfPrecision) => Err(OxiNumError::Precision(
128            format!("rational {x} not exactly representable as f64").into(),
129        )),
130    }
131}
132
133// ---------------------------------------------------------------------------
134// Mixed-number parsing
135// ---------------------------------------------------------------------------
136
137/// Parse a mixed-number string.
138///
139/// Accepts the following forms:
140///
141/// * `"3"` — integer (delegates to `RBig::from_str`)
142/// * `"3/4"` — proper or improper fraction (delegates to `RBig::from_str`)
143/// * `"-3/4"`, `"3/-4"` — signed fractions (delegates to `RBig::from_str`)
144/// * `"1 3/4"` — mixed number, equal to `7/4`
145/// * `"-2 1/3"` — negative mixed number, equal to `-7/3`
146///   (the sign binds to the whole value, not just the integer part)
147///
148/// Whitespace between the integer and fractional parts is any sequence of
149/// ASCII whitespace.
150///
151/// # Errors
152///
153/// Returns [`OxiNumError::Parse`] if either part fails to parse, if the
154/// fractional component is not positive, or if the string is malformed.
155///
156/// # Examples
157///
158/// ```
159/// use oxinum_rational::{parse_mixed, RBig, IBig, UBig};
160/// let r = parse_mixed("1 3/4").unwrap();
161/// assert_eq!(r, RBig::from_parts(IBig::from(7), UBig::from(4u32)));
162/// let neg = parse_mixed("-2 1/3").unwrap();
163/// assert_eq!(neg, RBig::from_parts(IBig::from(-7), UBig::from(3u32)));
164/// let plain = parse_mixed("3/4").unwrap();
165/// assert_eq!(plain, RBig::from_parts(IBig::from(3), UBig::from(4u32)));
166/// ```
167pub fn parse_mixed(s: &str) -> OxiNumResult<RBig> {
168    let trimmed = s.trim();
169    if trimmed.is_empty() {
170        return Err(OxiNumError::Parse("empty mixed-number string".into()));
171    }
172
173    // Locate the first whitespace gap.  If none, fall through to the plain
174    // RBig parser (which accepts "3", "3/4", "-3/4", etc.).
175    let split_at = trimmed.find(char::is_whitespace);
176    let Some(idx) = split_at else {
177        return RBig::from_str(trimmed)
178            .map_err(|e| OxiNumError::Parse(format!("invalid rational: {e}").into()));
179    };
180
181    let int_part_str = &trimmed[..idx];
182    let frac_part_str = trimmed[idx..].trim_start();
183    if frac_part_str.is_empty() {
184        return Err(OxiNumError::Parse(
185            "missing fractional part in mixed number".into(),
186        ));
187    }
188    if !frac_part_str.contains('/') {
189        return Err(OxiNumError::Parse(
190            format!("expected fraction after whole part, got {frac_part_str:?}").into(),
191        ));
192    }
193
194    let int_part = IBig::from_str(int_part_str).map_err(|e| {
195        OxiNumError::Parse(format!("invalid integer part {int_part_str:?}: {e}").into())
196    })?;
197    let frac_part = RBig::from_str(frac_part_str).map_err(|e| {
198        OxiNumError::Parse(format!("invalid fractional part {frac_part_str:?}: {e}").into())
199    })?;
200
201    // Well-formed mixed numbers have a non-negative fractional part.
202    if frac_part.numerator() < &IBig::ZERO {
203        return Err(OxiNumError::Parse(
204            "fractional part of a mixed number must be non-negative".into(),
205        ));
206    }
207
208    // Combine: the sign of the integer part rules the whole value.  We
209    // build `|int| + frac` then negate if necessary.  This correctly
210    // handles `"-0 1/3"` (rare but valid) as `+1/3`.
211    let neg = int_part < IBig::ZERO;
212    let abs_int = if neg { -&int_part } else { int_part };
213    let combined = RBig::from(abs_int) + frac_part;
214    let result = if neg { -combined } else { combined };
215    Ok(result)
216}
217
218/// Newtype wrapper providing [`FromStr`] for the mixed-number format.
219///
220/// `RBig` already implements `FromStr` for the standard `"a/b"` syntax;
221/// this newtype extends parsing to also accept `"1 3/4"` while keeping
222/// the orphan rule satisfied.
223///
224/// # Examples
225///
226/// ```
227/// use oxinum_rational::{MixedNumber, RBig, IBig, UBig};
228/// use std::str::FromStr;
229/// let m: MixedNumber = "1 3/4".parse().unwrap();
230/// assert_eq!(m.0, RBig::from_parts(IBig::from(7), UBig::from(4u32)));
231/// let n = MixedNumber::from_str("-2 1/3").unwrap();
232/// assert_eq!(n.0, RBig::from_parts(IBig::from(-7), UBig::from(3u32)));
233/// ```
234#[derive(Debug, Clone, PartialEq, Eq)]
235#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
236pub struct MixedNumber(pub RBig);
237
238impl FromStr for MixedNumber {
239    type Err = OxiNumError;
240
241    fn from_str(s: &str) -> Result<Self, Self::Err> {
242        parse_mixed(s).map(MixedNumber)
243    }
244}
245
246impl core::fmt::Display for MixedNumber {
247    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
248        // Render as a mixed number when the absolute value is improper
249        // (|num| > den) and the denominator is not 1.  Falls back to the
250        // standard `RBig` display otherwise.
251        let num = self.0.numerator();
252        let den_u: &UBig = self.0.denominator();
253        let den_i: IBig = den_u.clone().into();
254        if den_i == IBig::ONE {
255            return write!(f, "{}", num);
256        }
257        let abs_num = if num < &IBig::ZERO {
258            -num.clone()
259        } else {
260            num.clone()
261        };
262        if abs_num < den_i {
263            // proper fraction
264            return write!(f, "{}", self.0);
265        }
266        let whole = &abs_num / &den_i;
267        let rem = abs_num - &whole * &den_i;
268        let sign = if num < &IBig::ZERO { "-" } else { "" };
269        if rem == IBig::ZERO {
270            write!(f, "{}{}", sign, whole)
271        } else {
272            write!(f, "{}{} {}/{}", sign, whole, rem, den_i)
273        }
274    }
275}
276
277// ---------------------------------------------------------------------------
278// Tests
279// ---------------------------------------------------------------------------
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn from_f64_half() {
287        let r = from_f64(0.5).expect("finite");
288        assert_eq!(r, RBig::from_parts(IBig::from(1), UBig::from(2u32)));
289    }
290
291    #[test]
292    fn from_f64_negative() {
293        let r = from_f64(-0.125).expect("finite");
294        assert_eq!(r, RBig::from_parts(IBig::from(-1), UBig::from(8u32)));
295    }
296
297    #[test]
298    fn from_f64_zero() {
299        let r = from_f64(0.0).expect("finite");
300        assert_eq!(r, RBig::ZERO);
301    }
302
303    #[test]
304    fn from_f64_one_tenth_is_dyadic() {
305        // 0.1 in binary is the dyadic fraction
306        //   3602879701896397 / 2^55
307        // so the exact RBig must reduce to numerator 3602879701896397,
308        // denominator 2^55 / gcd(...) and the value must equal the original f64.
309        let original = 0.1_f64;
310        let r = from_f64(original).expect("finite");
311        // Round-trip back through to_f64_exact must succeed (it's dyadic).
312        let back = to_f64_exact(&r).expect("exact");
313        assert_eq!(back, original);
314        // The denominator must be a power of two.
315        let den = r.denominator().clone();
316        let den_i: IBig = den.into();
317        // Repeatedly halve via division: a power of two has bit_count == 1.
318        let two = IBig::from(2);
319        let mut count_bits = 0u32;
320        let mut tmp = den_i;
321        while tmp > IBig::ZERO {
322            if (&tmp % &two) == IBig::ONE {
323                count_bits += 1;
324            }
325            tmp /= &two;
326        }
327        assert_eq!(count_bits, 1, "0.1 denominator must be a power of two");
328    }
329
330    #[test]
331    fn from_f64_nan_errors() {
332        assert!(from_f64(f64::NAN).is_err());
333    }
334
335    #[test]
336    fn from_f64_inf_errors() {
337        assert!(from_f64(f64::INFINITY).is_err());
338        assert!(from_f64(f64::NEG_INFINITY).is_err());
339    }
340
341    #[test]
342    fn from_f32_basic() {
343        let r = from_f32(0.5_f32).expect("finite");
344        assert_eq!(r, RBig::from_parts(IBig::from(1), UBig::from(2u32)));
345        assert!(from_f32(f32::NAN).is_err());
346        assert!(from_f32(f32::INFINITY).is_err());
347    }
348
349    #[test]
350    fn to_f64_third() {
351        let third = RBig::from_parts(IBig::from(1), UBig::from(3u32));
352        let v = to_f64(&third);
353        assert!((v - 1.0_f64 / 3.0).abs() < 1e-15);
354    }
355
356    #[test]
357    fn to_f64_exact_dyadic_ok() {
358        let r = RBig::from_parts(IBig::from(7), UBig::from(8u32));
359        assert_eq!(to_f64_exact(&r).expect("exact"), 0.875);
360    }
361
362    #[test]
363    fn to_f64_exact_third_errors() {
364        let r = RBig::from_parts(IBig::from(1), UBig::from(3u32));
365        assert!(matches!(to_f64_exact(&r), Err(OxiNumError::Precision(_))));
366    }
367
368    #[test]
369    fn f64_roundtrip_dyadic() {
370        // Every dyadic rational that fits in f64 must round-trip exactly.
371        for &orig in &[
372            0.5_f64,
373            -0.5,
374            0.25,
375            -0.125,
376            0.875,
377            1.0,
378            -1.0,
379            2.0_f64.powi(20),
380            2.0_f64.powi(-20),
381            std::f64::consts::PI, // dyadic representation of pi-as-f64
382        ] {
383            let r = from_f64(orig).expect("finite");
384            let back = to_f64_exact(&r).expect("exact roundtrip");
385            assert_eq!(back, orig, "roundtrip failed for {orig}");
386        }
387    }
388
389    #[test]
390    fn parse_mixed_basic() {
391        let r = parse_mixed("1 3/4").expect("ok");
392        assert_eq!(r, RBig::from_parts(IBig::from(7), UBig::from(4u32)));
393    }
394
395    #[test]
396    fn parse_mixed_negative() {
397        // "-2 1/3" must be -7/3, NOT -5/3
398        let r = parse_mixed("-2 1/3").expect("ok");
399        assert_eq!(r, RBig::from_parts(IBig::from(-7), UBig::from(3u32)));
400    }
401
402    #[test]
403    fn parse_mixed_plain_fraction() {
404        let r = parse_mixed("3/4").expect("ok");
405        assert_eq!(r, RBig::from_parts(IBig::from(3), UBig::from(4u32)));
406    }
407
408    #[test]
409    fn parse_mixed_plain_integer() {
410        let r = parse_mixed("5").expect("ok");
411        assert_eq!(r, RBig::from(5u32));
412    }
413
414    #[test]
415    fn parse_mixed_with_whitespace() {
416        let r = parse_mixed("  1   3/4  ").expect("ok");
417        assert_eq!(r, RBig::from_parts(IBig::from(7), UBig::from(4u32)));
418    }
419
420    #[test]
421    fn parse_mixed_empty_errors() {
422        assert!(parse_mixed("").is_err());
423        assert!(parse_mixed("   ").is_err());
424    }
425
426    #[test]
427    fn parse_mixed_bad_integer_errors() {
428        assert!(parse_mixed("abc 1/2").is_err());
429    }
430
431    #[test]
432    fn parse_mixed_bad_fraction_errors() {
433        assert!(parse_mixed("1 xyz").is_err());
434        assert!(parse_mixed("1 2").is_err()); // missing slash
435    }
436
437    #[test]
438    fn parse_mixed_negative_fractional_rejected() {
439        assert!(parse_mixed("1 -1/2").is_err());
440    }
441
442    #[test]
443    fn mixed_number_from_str() {
444        let m: MixedNumber = "1 3/4".parse().expect("ok");
445        assert_eq!(m.0, RBig::from_parts(IBig::from(7), UBig::from(4u32)));
446    }
447
448    #[test]
449    fn mixed_number_display_proper() {
450        let m = MixedNumber(RBig::from_parts(IBig::from(3), UBig::from(4u32)));
451        assert_eq!(m.to_string(), "3/4");
452    }
453
454    #[test]
455    fn mixed_number_display_improper() {
456        let m = MixedNumber(RBig::from_parts(IBig::from(7), UBig::from(4u32)));
457        assert_eq!(m.to_string(), "1 3/4");
458    }
459
460    #[test]
461    fn mixed_number_display_negative_improper() {
462        let m = MixedNumber(RBig::from_parts(IBig::from(-7), UBig::from(3u32)));
463        assert_eq!(m.to_string(), "-2 1/3");
464    }
465
466    #[test]
467    fn mixed_number_display_integer() {
468        let m = MixedNumber(RBig::from(5u32));
469        assert_eq!(m.to_string(), "5");
470    }
471
472    #[test]
473    fn mixed_number_roundtrip_display_parse() {
474        // "1 3/4" -> 7/4 -> Display -> "1 3/4" -> parse -> 7/4
475        let m1: MixedNumber = "1 3/4".parse().expect("ok");
476        let rendered = m1.to_string();
477        assert_eq!(rendered, "1 3/4");
478        let m2: MixedNumber = rendered.parse().expect("ok");
479        assert_eq!(m1, m2);
480    }
481
482    // ----- serde JSON round-trip (feature-gated) ---------------------------
483
484    #[cfg(feature = "serde")]
485    #[test]
486    fn rbig_serde_json_roundtrip() {
487        let r = RBig::from_parts(IBig::from(355), UBig::from(113u32));
488        let json = serde_json::to_string(&r).expect("serialize");
489        let back: RBig = serde_json::from_str(&json).expect("deserialize");
490        assert_eq!(r, back);
491    }
492
493    #[cfg(feature = "serde")]
494    #[test]
495    fn rbig_serde_json_roundtrip_negative() {
496        let r = RBig::from_parts(IBig::from(-7), UBig::from(3u32));
497        let json = serde_json::to_string(&r).expect("serialize");
498        let back: RBig = serde_json::from_str(&json).expect("deserialize");
499        assert_eq!(r, back);
500    }
501
502    #[cfg(feature = "serde")]
503    #[test]
504    fn mixed_number_serde_json_roundtrip() {
505        let m = MixedNumber(RBig::from_parts(IBig::from(7), UBig::from(4u32)));
506        let json = serde_json::to_string(&m).expect("serialize");
507        let back: MixedNumber = serde_json::from_str(&json).expect("deserialize");
508        assert_eq!(m, back);
509    }
510}