Skip to main content

lemma/computation/
rational.rs

1//! Exact rational arithmetic bridge between `rust_decimal::Decimal` and `num_rational::Ratio<i128>`.
2
3use num_integer::Roots;
4use num_rational::Ratio;
5use rust_decimal::Decimal;
6use rust_decimal::MathematicalOps;
7use std::fmt;
8
9pub type RationalInteger = Ratio<i128>;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum NumericFailure {
13    DivisionByZero,
14    Overflow,
15    Irrational,
16    CommitFailed,
17}
18
19impl fmt::Display for NumericFailure {
20    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
21        match self {
22            NumericFailure::DivisionByZero => formatter.write_str("division by zero"),
23            NumericFailure::Overflow => formatter.write_str("numeric overflow"),
24            NumericFailure::Irrational => formatter.write_str("irrational numeric result"),
25            NumericFailure::CommitFailed => {
26                formatter.write_str("failed to commit rational to decimal")
27            }
28        }
29    }
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum NumericOperation {
34    Add,
35    Subtract,
36    Multiply,
37    Divide,
38    Modulo,
39    Power,
40}
41
42pub fn rational_one() -> RationalInteger {
43    RationalInteger::new(1, 1)
44}
45
46pub fn rational_zero() -> RationalInteger {
47    RationalInteger::new(0, 1)
48}
49
50pub fn rational_is_zero(rational: &RationalInteger) -> bool {
51    *rational.numer() == 0
52}
53
54/// Absolute value in ℚ (negates a negative numerator; zero and positive unchanged).
55pub fn rational_abs(rational: &RationalInteger) -> RationalInteger {
56    if *rational.numer() >= 0 {
57        *rational
58    } else {
59        RationalInteger::new(-rational.numer(), *rational.denom()).reduced()
60    }
61}
62
63/// Truncates a rational toward zero as a rational with denominator 1.
64pub fn rational_trunc(rational: &RationalInteger) -> RationalInteger {
65    RationalInteger::new(rational.numer() / rational.denom(), 1)
66}
67
68pub fn decimal_to_rational(decimal: Decimal) -> Result<RationalInteger, NumericFailure> {
69    let mantissa = decimal.mantissa();
70    if mantissa == 0 {
71        return Ok(RationalInteger::new(0, 1));
72    }
73    let scale = decimal.scale();
74    let mut denominator = 1i128;
75    for _ in 0..scale {
76        denominator = denominator
77            .checked_mul(10)
78            .ok_or(NumericFailure::Overflow)?;
79    }
80    Ok(RationalInteger::new(mantissa, denominator).reduced())
81}
82
83/// Commit a rational to stored `Decimal` with a single division (finite precision).
84pub fn commit_rational_to_decimal(rational: &RationalInteger) -> Result<Decimal, NumericFailure> {
85    let reduced = rational.reduced();
86    let numerator = *reduced.numer();
87    let denominator = *reduced.denom();
88    if denominator == 0 {
89        return Err(NumericFailure::CommitFailed);
90    }
91    if numerator == 0 {
92        return Ok(Decimal::ZERO);
93    }
94    let numerator_decimal = decimal_from_i128(numerator)?;
95    let denominator_decimal = decimal_from_i128(denominator)?;
96    numerator_decimal
97        .checked_div(denominator_decimal)
98        .ok_or(NumericFailure::CommitFailed)
99}
100
101/// Format for human display: commit to decimal when possible, else reduced `numer/denom`.
102pub fn rational_to_display_str(rational: &RationalInteger) -> String {
103    match commit_rational_to_decimal(rational) {
104        Ok(decimal) => decimal_to_display_str(&decimal),
105        Err(_) => rational_fraction_str(rational),
106    }
107}
108
109/// Wire/API format: exact decimal string only. Never emits a fraction.
110pub fn rational_to_wire_str(rational: &RationalInteger) -> Result<String, NumericFailure> {
111    commit_rational_to_decimal(rational).map(|decimal| decimal_to_display_str(&decimal))
112}
113
114fn decimal_to_display_str(decimal: &Decimal) -> String {
115    let normalized = decimal.normalize();
116    if normalized.fract().is_zero() {
117        normalized.trunc().to_string()
118    } else {
119        normalized.to_string()
120    }
121}
122
123fn rational_fraction_str(rational: &RationalInteger) -> String {
124    let reduced = rational.reduced();
125    let numer = *reduced.numer();
126    let denom = *reduced.denom();
127    if denom == 1 {
128        numer.to_string()
129    } else {
130        format!("{numer}/{denom}")
131    }
132}
133
134fn decimal_from_i128(value: i128) -> Result<Decimal, NumericFailure> {
135    let max_mantissa = Decimal::MAX.mantissa();
136    let min_mantissa = Decimal::MIN.mantissa();
137    if value > max_mantissa || value < min_mantissa {
138        return Err(NumericFailure::CommitFailed);
139    }
140    Ok(Decimal::from(value))
141}
142
143pub fn rational_operation(
144    left: &RationalInteger,
145    operation: NumericOperation,
146    right: &RationalInteger,
147) -> Result<RationalInteger, NumericFailure> {
148    match operation {
149        NumericOperation::Add => checked_add(left, right),
150        NumericOperation::Subtract => checked_sub(left, right),
151        NumericOperation::Multiply => checked_mul(left, right),
152        NumericOperation::Divide => {
153            if rational_is_zero(right) {
154                return Err(NumericFailure::DivisionByZero);
155            }
156            checked_div(left, right)
157        }
158        NumericOperation::Modulo => {
159            if rational_is_zero(right) {
160                return Err(NumericFailure::DivisionByZero);
161            }
162            let quotient = checked_div(left, right)?;
163            let truncated = rational_trunc(&quotient);
164            let product = checked_mul(&truncated, right)?;
165            checked_sub(left, &product)
166        }
167        NumericOperation::Power => checked_rational_power(left, right),
168    }
169}
170
171/// Exact rational operation; on overflow, irrational power, or commit failure on operands,
172/// fall back to Decimal arithmetic (legacy pipeline semantics) and lift the result.
173pub fn rational_operation_with_fallback(
174    left: &RationalInteger,
175    operation: NumericOperation,
176    right: &RationalInteger,
177) -> Result<RationalInteger, NumericFailure> {
178    match rational_operation(left, operation, right) {
179        Ok(result) => Ok(result),
180        Err(NumericFailure::DivisionByZero) => Err(NumericFailure::DivisionByZero),
181        Err(_) => approximate_rational_operation(left, operation, right),
182    }
183}
184
185fn approximate_rational_operation(
186    left: &RationalInteger,
187    operation: NumericOperation,
188    right: &RationalInteger,
189) -> Result<RationalInteger, NumericFailure> {
190    let left_decimal = commit_rational_to_decimal(left)?;
191    let right_decimal = commit_rational_to_decimal(right)?;
192    let result_decimal = decimal_arithmetic(left_decimal, operation, right_decimal)?;
193    decimal_to_rational(result_decimal)
194}
195
196fn decimal_arithmetic(
197    left: Decimal,
198    operation: NumericOperation,
199    right: Decimal,
200) -> Result<Decimal, NumericFailure> {
201    match operation {
202        NumericOperation::Add => left.checked_add(right).ok_or(NumericFailure::CommitFailed),
203        NumericOperation::Subtract => left.checked_sub(right).ok_or(NumericFailure::CommitFailed),
204        NumericOperation::Multiply => left.checked_mul(right).ok_or(NumericFailure::CommitFailed),
205        NumericOperation::Divide => {
206            if right.is_zero() {
207                return Err(NumericFailure::DivisionByZero);
208            }
209            left.checked_div(right).ok_or(NumericFailure::CommitFailed)
210        }
211        NumericOperation::Modulo => {
212            if right.is_zero() {
213                return Err(NumericFailure::DivisionByZero);
214            }
215            let quotient = left
216                .checked_div(right)
217                .ok_or(NumericFailure::CommitFailed)?;
218            let truncated = quotient.trunc();
219            let product = truncated
220                .checked_mul(right)
221                .ok_or(NumericFailure::CommitFailed)?;
222            left.checked_sub(product)
223                .ok_or(NumericFailure::CommitFailed)
224        }
225        NumericOperation::Power => decimal_power(left, right),
226    }
227}
228
229fn decimal_is_half(exponent: Decimal) -> bool {
230    exponent
231        .checked_mul(Decimal::TWO)
232        .is_some_and(|doubled| doubled == Decimal::ONE)
233}
234
235fn decimal_power(base: Decimal, exponent: Decimal) -> Result<Decimal, NumericFailure> {
236    if exponent.fract().is_zero() {
237        let exponent_i64 =
238            i64::try_from(exponent.trunc().mantissa()).map_err(|_| NumericFailure::Overflow)?;
239        return base
240            .checked_powi(exponent_i64)
241            .ok_or(NumericFailure::CommitFailed);
242    }
243    if decimal_is_half(exponent) {
244        return base.sqrt().ok_or(NumericFailure::Irrational);
245    }
246    Err(NumericFailure::Irrational)
247}
248
249pub fn checked_add(
250    left: &RationalInteger,
251    right: &RationalInteger,
252) -> Result<RationalInteger, NumericFailure> {
253    left.numer()
254        .checked_mul(*right.denom())
255        .and_then(|left_cross| {
256            right
257                .numer()
258                .checked_mul(*left.denom())
259                .map(|right_cross| (left_cross, right_cross))
260        })
261        .and_then(|(left_cross, right_cross)| {
262            left_cross
263                .checked_add(right_cross)
264                .map(|numerator| (numerator, left.denom().checked_mul(*right.denom())))
265        })
266        .and_then(|(numerator, denominator)| {
267            if denominator == Some(0) {
268                None
269            } else {
270                denominator
271                    .map(|denominator| RationalInteger::new(numerator, denominator).reduced())
272            }
273        })
274        .ok_or(NumericFailure::Overflow)
275}
276
277pub fn checked_sub(
278    left: &RationalInteger,
279    right: &RationalInteger,
280) -> Result<RationalInteger, NumericFailure> {
281    left.numer()
282        .checked_mul(*right.denom())
283        .and_then(|left_cross| {
284            right
285                .numer()
286                .checked_mul(*left.denom())
287                .map(|right_cross| (left_cross, right_cross))
288        })
289        .and_then(|(left_cross, right_cross)| {
290            left_cross
291                .checked_sub(right_cross)
292                .map(|numerator| (numerator, left.denom().checked_mul(*right.denom())))
293        })
294        .and_then(|(numerator, denominator)| {
295            if denominator == Some(0) {
296                None
297            } else {
298                denominator
299                    .map(|denominator| RationalInteger::new(numerator, denominator).reduced())
300            }
301        })
302        .ok_or(NumericFailure::Overflow)
303}
304
305pub fn checked_mul(
306    left: &RationalInteger,
307    right: &RationalInteger,
308) -> Result<RationalInteger, NumericFailure> {
309    left.numer()
310        .checked_mul(*right.numer())
311        .and_then(|numerator| {
312            left.denom()
313                .checked_mul(*right.denom())
314                .map(|denominator| (numerator, denominator))
315        })
316        .map(|(numerator, denominator)| RationalInteger::new(numerator, denominator).reduced())
317        .ok_or(NumericFailure::Overflow)
318}
319
320pub fn checked_div(
321    left: &RationalInteger,
322    right: &RationalInteger,
323) -> Result<RationalInteger, NumericFailure> {
324    if *right.numer() == 0 {
325        return Err(NumericFailure::DivisionByZero);
326    }
327    left.numer()
328        .checked_mul(*right.denom())
329        .and_then(|numerator| {
330            left.denom()
331                .checked_mul(*right.numer())
332                .map(|denominator| (numerator, denominator))
333        })
334        .map(|(numerator, denominator)| RationalInteger::new(numerator, denominator).reduced())
335        .ok_or(NumericFailure::Overflow)
336}
337
338pub fn checked_pow_i32(
339    base: &RationalInteger,
340    exponent: i32,
341) -> Result<RationalInteger, NumericFailure> {
342    if exponent == 0 {
343        return Ok(rational_one());
344    }
345    if exponent < 0 {
346        if *base.numer() == 0 {
347            return Err(NumericFailure::DivisionByZero);
348        }
349        let positive_base = RationalInteger::new(*base.denom(), *base.numer()).reduced();
350        return checked_pow_i32(&positive_base, -exponent);
351    }
352    let mut result = rational_one();
353    let mut factor = base.reduced();
354    let mut remaining = exponent as u32;
355    while remaining > 0 {
356        if remaining % 2 == 1 {
357            result = checked_mul(&result, &factor)?;
358        }
359        remaining /= 2;
360        if remaining > 0 {
361            factor = checked_mul(&factor, &factor)?;
362        }
363    }
364    Ok(result)
365}
366
367pub fn checked_rational_power(
368    base: &RationalInteger,
369    exponent: &RationalInteger,
370) -> Result<RationalInteger, NumericFailure> {
371    let exp_numer = *exponent.numer();
372    let exp_denom = *exponent.denom();
373
374    assert!(
375        exp_denom > 0,
376        "BUG: rational exponent must have positive denominator (canonical Ratio invariant)"
377    );
378
379    if exp_denom == 1 {
380        let exponent = i32::try_from(exp_numer).map_err(|_| NumericFailure::Overflow)?;
381        return checked_pow_i32(base, exponent);
382    }
383
384    if *base.numer() == 0 {
385        if exp_numer <= 0 {
386            return Err(NumericFailure::DivisionByZero);
387        }
388        return Ok(RationalInteger::new(0, 1));
389    }
390
391    let abs_exp_numer = exp_numer.unsigned_abs();
392    let abs_exp_i32 = i32::try_from(abs_exp_numer).map_err(|_| NumericFailure::Overflow)?;
393    let raised = checked_pow_i32(base, abs_exp_i32)?;
394
395    let root_degree = u32::try_from(exp_denom).map_err(|_| NumericFailure::Overflow)?;
396
397    let raised_numer = *raised.numer();
398    let raised_denom = *raised.denom();
399
400    let (numer_root, numer_negative) = if raised_numer < 0 {
401        if root_degree % 2 == 0 {
402            return Err(NumericFailure::Irrational);
403        }
404        (raised_numer.unsigned_abs().nth_root(root_degree), true)
405    } else {
406        ((raised_numer as u128).nth_root(root_degree), false)
407    };
408
409    let denom_root = (raised_denom as u128).nth_root(root_degree);
410
411    let numer_root_i128 = i128::try_from(numer_root).map_err(|_| NumericFailure::Overflow)?;
412    let denom_root_i128 = i128::try_from(denom_root).map_err(|_| NumericFailure::Overflow)?;
413
414    let numer_reconstructed = numer_root_i128
415        .checked_pow(root_degree)
416        .ok_or(NumericFailure::Overflow)?;
417    let denom_reconstructed = denom_root_i128
418        .checked_pow(root_degree)
419        .ok_or(NumericFailure::Overflow)?;
420
421    if numer_reconstructed != raised_numer.unsigned_abs() as i128 {
422        return Err(NumericFailure::Irrational);
423    }
424    if denom_reconstructed != raised_denom {
425        return Err(NumericFailure::Irrational);
426    }
427
428    let signed_numer = if numer_negative {
429        -numer_root_i128
430    } else {
431        numer_root_i128
432    };
433
434    let result = RationalInteger::new(signed_numer, denom_root_i128).reduced();
435
436    if exp_numer < 0 {
437        if *result.numer() == 0 {
438            return Err(NumericFailure::DivisionByZero);
439        }
440        Ok(RationalInteger::new(*result.denom(), *result.numer()).reduced())
441    } else {
442        Ok(result)
443    }
444}
445
446/// Multiply a quantity magnitude by a unit conversion ratio in ℚ, then commit once.
447pub fn convert_quantity_magnitude_rational(
448    magnitude: RationalInteger,
449    from_factor: &RationalInteger,
450    to_factor: &RationalInteger,
451) -> Result<RationalInteger, NumericFailure> {
452    let ratio = checked_div(from_factor, to_factor)?;
453    checked_mul(&magnitude, &ratio)
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use rust_decimal::Decimal;
460    use std::str::FromStr;
461
462    #[test]
463    fn rational_zero_is_zero() {
464        assert!(rational_is_zero(&rational_zero()));
465    }
466
467    #[test]
468    fn decimal_one_half_lifts_to_rational() {
469        let decimal = Decimal::from_str("0.5").unwrap();
470        let rational = decimal_to_rational(decimal).unwrap();
471        assert_eq!(rational, RationalInteger::new(1, 2));
472    }
473
474    #[test]
475    fn commit_one_third_to_decimal() {
476        let rational = RationalInteger::new(1, 3);
477        let decimal = commit_rational_to_decimal(&rational).unwrap();
478        let expected = Decimal::from_str("0.3333333333333333333333333333").unwrap();
479        assert_eq!(decimal, expected);
480    }
481
482    #[test]
483    fn checked_mul_integer() {
484        let left = RationalInteger::new(50, 1);
485        let right = RationalInteger::new(86400, 1);
486        let product = checked_mul(&left, &right).unwrap();
487        assert_eq!(product, RationalInteger::new(4_320_000, 1));
488    }
489
490    #[test]
491    fn checked_pow_negative_exponent_inverts_base() {
492        let hour_factor = RationalInteger::new(3600, 1);
493        let inverse = checked_pow_i32(&hour_factor, -1).unwrap();
494        assert_eq!(inverse, RationalInteger::new(1, 3600));
495    }
496
497    #[test]
498    fn rational_operation_divide_by_zero() {
499        let left = RationalInteger::new(1, 1);
500        let right = RationalInteger::new(0, 1);
501        let failure = rational_operation(&left, NumericOperation::Divide, &right).unwrap_err();
502        assert_eq!(failure, NumericFailure::DivisionByZero);
503    }
504
505    #[test]
506    fn rational_operation_power_irrational() {
507        let base = RationalInteger::new(2, 1);
508        let exponent = RationalInteger::new(1, 2);
509        let failure = rational_operation(&base, NumericOperation::Power, &exponent).unwrap_err();
510        assert_eq!(failure, NumericFailure::Irrational);
511    }
512
513    #[test]
514    fn rational_operation_power_exact() {
515        let base = RationalInteger::new(4, 1);
516        let exponent = RationalInteger::new(1, 2);
517        let result = rational_operation(&base, NumericOperation::Power, &exponent).unwrap();
518        assert_eq!(result, RationalInteger::new(2, 1));
519    }
520
521    #[test]
522    fn rational_operation_add() {
523        let left = RationalInteger::new(1, 3);
524        let right = RationalInteger::new(1, 6);
525        let sum = rational_operation(&left, NumericOperation::Add, &right).unwrap();
526        assert_eq!(sum, RationalInteger::new(1, 2));
527    }
528
529    #[test]
530    fn rational_operation_with_fallback_add_after_overflow_path() {
531        let left = RationalInteger::new(1, 3);
532        let right = RationalInteger::new(1, 6);
533        let sum = rational_operation_with_fallback(&left, NumericOperation::Add, &right).unwrap();
534        assert_eq!(sum, RationalInteger::new(1, 2));
535    }
536
537    #[test]
538    fn rational_operation_with_fallback_power_sqrt_via_decimal() {
539        let result = rational_operation_with_fallback(
540            &RationalInteger::new(2, 1),
541            NumericOperation::Power,
542            &RationalInteger::new(1, 2),
543        )
544        .unwrap();
545        assert_eq!(
546            commit_rational_to_decimal(&result).unwrap(),
547            commit_rational_to_decimal(&RationalInteger::new(2, 1))
548                .unwrap()
549                .sqrt()
550                .unwrap(),
551        );
552    }
553
554    #[test]
555    fn rational_abs_negates_negative_numerator() {
556        let negative = RationalInteger::new(-172_800, 1);
557        assert_eq!(rational_abs(&negative), RationalInteger::new(172_800, 1));
558    }
559
560    #[test]
561    fn rational_to_wire_str_rejects_uncommittable() {
562        let too_large = RationalInteger::new(i128::MAX, 1);
563        assert!(commit_rational_to_decimal(&too_large).is_err());
564        assert!(rational_to_wire_str(&too_large).is_err());
565    }
566
567    #[test]
568    fn rational_to_wire_str_matches_commit_for_committable() {
569        let rational = RationalInteger::new(37, 1);
570        assert_eq!(rational_to_wire_str(&rational).unwrap(), "37");
571    }
572
573    #[test]
574    fn rational_to_display_str_falls_back_to_fraction_when_commit_fails() {
575        // 1/3 commits to a repeating decimal in rust_decimal; if commit ever fails,
576        // display falls back to reduced fraction notation.
577        let rational = RationalInteger::new(355, 113);
578        let display = rational_to_display_str(&rational);
579        assert!(
580            display.contains('/') || commit_rational_to_decimal(&rational).is_ok(),
581            "display must be either committable decimal or fraction, got {display}"
582        );
583    }
584}