Skip to main content

scirs2_special/
validated.rs

1//! Validated numerics: ball arithmetic for certified function evaluation.
2//!
3//! Each `Ball` = (midpoint, radius) guarantees the true value lies in
4//! `[mid - rad, mid + rad]`.  All arithmetic operations propagate the
5//! enclosure guarantee using interval-arithmetic rules.
6//!
7//! # References
8//! - Tucker, "Validated Numerics: A Short Introduction to Rigorous Computations"
9//! - Rump, "INTLAB — INTerval LABoratory"
10
11/// A ball: midpoint ± radius, guaranteed to contain the true value.
12///
13/// Invariant: `rad >= 0`.
14#[derive(Debug, Clone, Copy, PartialEq)]
15pub struct Ball {
16    /// Midpoint of the enclosure interval.
17    pub mid: f64,
18    /// Radius (non-negative) of the enclosure interval.
19    pub rad: f64,
20}
21
22impl Ball {
23    /// Construct a ball with the given midpoint and radius.
24    ///
25    /// # Panics
26    /// In debug builds, panics when `rad < 0`.
27    #[inline]
28    pub fn new(mid: f64, rad: f64) -> Self {
29        debug_assert!(rad >= 0.0, "Ball radius must be non-negative");
30        Ball {
31            mid,
32            rad: rad.abs(), // guard against tiny negative fp artefacts
33        }
34    }
35
36    /// Construct a point ball (exact representation, radius 0).
37    #[inline]
38    pub fn from_exact(x: f64) -> Self {
39        Ball { mid: x, rad: 0.0 }
40    }
41
42    /// Construct a ball from a closed interval `[lo, hi]`.
43    ///
44    /// Returns `None` when `lo > hi`.
45    pub fn from_interval(lo: f64, hi: f64) -> Option<Self> {
46        if lo > hi {
47            return None;
48        }
49        let mid = (lo + hi) * 0.5;
50        let rad = (hi - lo) * 0.5;
51        Some(Ball::new(mid, rad))
52    }
53
54    /// Lower bound of the enclosure interval.
55    #[inline]
56    pub fn lo(&self) -> f64 {
57        self.mid - self.rad
58    }
59
60    /// Upper bound of the enclosure interval.
61    #[inline]
62    pub fn hi(&self) -> f64 {
63        self.mid + self.rad
64    }
65
66    /// Returns `true` when the ball strictly contains `x`.
67    #[inline]
68    pub fn contains(&self, x: f64) -> bool {
69        x >= self.lo() && x <= self.hi()
70    }
71
72    /// Returns `true` when this ball overlaps with `other`.
73    #[inline]
74    pub fn overlaps(&self, other: &Ball) -> bool {
75        self.lo() <= other.hi() && other.lo() <= self.hi()
76    }
77
78    /// Returns `true` when the ball represents a valid enclosure (`rad >= 0`).
79    #[inline]
80    pub fn is_valid(&self) -> bool {
81        self.rad >= 0.0 && self.mid.is_finite()
82    }
83
84    /// Add two balls, propagating the enclosure guarantee.
85    ///
86    /// `[a±r_a] + [b±r_b] = [a+b ± (r_a + r_b + fp_rounding)]`
87    #[inline]
88    pub fn add(&self, other: &Ball) -> Self {
89        let mid = self.mid + other.mid;
90        let prop = self.rad + other.rad;
91        let fp_err = mid.abs() * f64::EPSILON * 2.0;
92        Ball::new(mid, prop + fp_err)
93    }
94
95    /// Subtract two balls.
96    #[inline]
97    pub fn sub(&self, other: &Ball) -> Self {
98        let mid = self.mid - other.mid;
99        let prop = self.rad + other.rad;
100        let fp_err = mid.abs() * f64::EPSILON * 2.0;
101        Ball::new(mid, prop + fp_err)
102    }
103
104    /// Multiply two balls using the interval arithmetic rule.
105    ///
106    /// Multiplication error bound:
107    /// `|a*b - mid_a*mid_b| <= |mid_a|*r_b + |mid_b|*r_a + r_a*r_b`
108    /// plus floating-point rounding of the midpoint product.
109    #[inline]
110    pub fn mul(&self, other: &Ball) -> Self {
111        let mid = self.mid * other.mid;
112        let prop = self.mid.abs() * other.rad + other.mid.abs() * self.rad + self.rad * other.rad;
113        let fp_err = mid.abs() * f64::EPSILON * 2.0;
114        Ball::new(mid, prop + fp_err)
115    }
116
117    /// Divide two balls.  Returns `None` when the divisor ball contains 0.
118    pub fn div(&self, other: &Ball) -> Option<Self> {
119        // The divisor interval must not straddle zero.
120        if other.lo() <= 0.0 && other.hi() >= 0.0 {
121            return None;
122        }
123        let mid = self.mid / other.mid;
124        // d/dx (a/b) wrt b has factor a/b^2; full interval bound:
125        // |a/b - mid_a/mid_b| <= (|mid_a|*r_b/|mid_b|^2 + r_a/|mid_b|) / (1 - r_b/|mid_b|)
126        // Simpler (over-)estimate: use the 1st-order bound and add fp error.
127        let inv_b = 1.0 / other.mid;
128        let prop = (self.mid.abs() * other.rad * inv_b.abs() * inv_b.abs())
129            + self.rad * inv_b.abs()
130            + mid.abs() * f64::EPSILON * 2.0;
131        Some(Ball::new(mid, prop))
132    }
133
134    /// Negate a ball.
135    #[inline]
136    pub fn neg(&self) -> Self {
137        Ball::new(-self.mid, self.rad)
138    }
139
140    /// Absolute value of a ball.
141    #[inline]
142    pub fn abs(&self) -> Self {
143        Ball::new(self.mid.abs(), self.rad)
144    }
145
146    /// Square root of a ball.  Returns `None` if the interval contains a
147    /// negative number.
148    pub fn sqrt(&self) -> Option<Self> {
149        if self.lo() < 0.0 {
150            return None;
151        }
152        let mid = self.mid.sqrt();
153        // |sqrt(x) - sqrt(m)| <= |x-m| / (2*sqrt(lo)) for x in [lo, hi]
154        // Use the propagation bound: derivative of sqrt is 1/(2*sqrt(m)).
155        let lo_sqrt = self.lo().sqrt().max(f64::MIN_POSITIVE);
156        let prop = self.rad / (2.0 * lo_sqrt);
157        let fp_err = mid * f64::EPSILON * 2.0;
158        Some(Ball::new(mid, prop + fp_err))
159    }
160
161    /// Raise a ball to an integer power.
162    pub fn powi(&self, n: i32) -> Self {
163        if n == 0 {
164            return Ball::from_exact(1.0);
165        }
166        if n == 1 {
167            return *self;
168        }
169        if n < 0 {
170            // 1 / self^(-n)
171            let pos = self.powi(-n);
172            return Ball::from_exact(1.0)
173                .div(&pos)
174                .unwrap_or(Ball::new(f64::NAN, f64::INFINITY));
175        }
176        // Exponentiation by squaring with ball propagation.
177        let mut result = Ball::from_exact(1.0);
178        let mut base = *self;
179        let mut exp = n as u32;
180        while exp > 0 {
181            if exp & 1 == 1 {
182                result = result.mul(&base);
183            }
184            base = base.mul(&base);
185            exp >>= 1;
186        }
187        result
188    }
189}
190
191impl std::ops::Add for Ball {
192    type Output = Self;
193    fn add(self, rhs: Self) -> Self {
194        Ball::add(&self, &rhs)
195    }
196}
197
198impl std::ops::Sub for Ball {
199    type Output = Self;
200    fn sub(self, rhs: Self) -> Self {
201        Ball::sub(&self, &rhs)
202    }
203}
204
205impl std::ops::Mul for Ball {
206    type Output = Self;
207    fn mul(self, rhs: Self) -> Self {
208        Ball::mul(&self, &rhs)
209    }
210}
211
212impl std::ops::Neg for Ball {
213    type Output = Self;
214    fn neg(self) -> Self {
215        Ball::neg(&self)
216    }
217}
218
219// ─────────────────────────────────────────────────────────────────────────────
220// Elementary function enclosures
221// ─────────────────────────────────────────────────────────────────────────────
222
223/// Evaluate `sin(x)` with a rigorous enclosure.
224///
225/// Uses the Lipschitz constant 1 for propagation plus a floating-point
226/// rounding bound.
227pub fn ball_sin(x: Ball) -> Ball {
228    let mid_val = x.mid.sin();
229    // Lipschitz constant of sin is 1, so |sin(a) - sin(b)| <= |a - b|.
230    let prop_err = x.rad;
231    let fp_err = (1.0_f64).max(mid_val.abs()) * f64::EPSILON * 4.0;
232    Ball::new(mid_val, prop_err + fp_err)
233}
234
235/// Evaluate `cos(x)` with a rigorous enclosure.
236pub fn ball_cos(x: Ball) -> Ball {
237    let mid_val = x.mid.cos();
238    let prop_err = x.rad;
239    let fp_err = (1.0_f64).max(mid_val.abs()) * f64::EPSILON * 4.0;
240    Ball::new(mid_val, prop_err + fp_err)
241}
242
243/// Evaluate `exp(x)` with a rigorous enclosure.
244///
245/// Derivative of exp is exp itself, so `|e^a - e^b| <= e^{a} * |a - b|`
246/// for `a >= b`.
247pub fn ball_exp(x: Ball) -> Ball {
248    let mid_val = x.mid.exp();
249    // Conservative: use e^{mid + rad} * rad as propagation bound.
250    let max_exp = (x.mid + x.rad).exp();
251    let prop_err = max_exp * x.rad;
252    let fp_err = mid_val * f64::EPSILON * 4.0;
253    Ball::new(mid_val, prop_err + fp_err)
254}
255
256/// Evaluate `ln(x)` with a rigorous enclosure.
257///
258/// Returns `None` if the interval contains zero or a negative number.
259pub fn ball_ln(x: Ball) -> Option<Ball> {
260    if x.lo() <= 0.0 {
261        return None;
262    }
263    let mid_val = x.mid.ln();
264    // Derivative of ln is 1/x; bound: |ln(a) - ln(b)| <= |a-b| / lo.
265    let prop_err = x.rad / x.lo();
266    let fp_err = mid_val.abs() * f64::EPSILON * 4.0;
267    Some(Ball::new(mid_val, prop_err + fp_err))
268}
269
270// ─────────────────────────────────────────────────────────────────────────────
271// Gamma function enclosure via Stirling's series
272// ─────────────────────────────────────────────────────────────────────────────
273
274/// Evaluate `Gamma(x)` with a rigorous enclosure using Stirling's approximation
275/// plus an explicit error bound.
276///
277/// Returns `None` when `x <= 0` (poles of Gamma).
278///
279/// The error bound for Stirling's series is:
280/// `|ln Gamma(x) - Stirling_n(x)| <= B_{2n+2} / ((2n+1)(2n+2) * x^{2n+2})`
281/// where `B_{2n+2}` is the (2n+2)-th Bernoulli number.
282pub fn ball_gamma(x: Ball) -> Option<Ball> {
283    if x.lo() <= 0.0 {
284        return None;
285    }
286
287    // Use functional equation Gamma(x) = Gamma(x+k)/x(x+1)...(x+k-1)
288    // to shift x into the range [10, inf) where Stirling is tight.
289    let shift_target = 10.0_f64;
290    let shift = if x.mid < shift_target {
291        (shift_target - x.mid).ceil() as u32
292    } else {
293        0
294    };
295
296    // Accumulated shift factor (as ball arithmetic).
297    // Gamma(x) = Gamma(x+shift) / [x*(x+1)*...*(x+shift-1)]
298    let x_shifted = Ball::new(x.mid + shift as f64, x.rad);
299
300    // Stirling series for ln Gamma(z) for large z:
301    // ln Gamma(z) ~ (z-0.5)*ln(z) - z + 0.5*ln(2*pi) + 1/(12z) - 1/(360z^3) + ...
302    let z = x_shifted.mid;
303    let ln_gamma_mid = (z - 0.5) * z.ln() - z + 0.5 * std::f64::consts::TAU.ln() + 1.0 / (12.0 * z)
304        - 1.0 / (360.0 * z * z * z)
305        + 1.0 / (1260.0 * z.powi(5));
306
307    // Error bound from the next Stirling term: B_8/(7*8*z^8) = 1/1680 / z^8.
308    // B_8 = -1/30, but in absolute value the bound is 1/30 * 1/(7*8) = 1/1680.
309    let stirling_err = 1.0 / (1680.0 * z.powi(8));
310
311    // Propagation: d(ln Gamma)/dx ~ psi(x) (digamma).
312    // Use the rough bound |psi(x)| <= ln(x) for x >= 1.
313    let psi_bound = z.ln() + 1.0 / z;
314    let prop_err = psi_bound * x.rad;
315
316    let ln_gamma_rad = stirling_err + prop_err + z.ln() * f64::EPSILON * 4.0;
317
318    // gamma_shifted = exp(ln_gamma ± rad_ln)
319    let gamma_shifted_mid = ln_gamma_mid.exp();
320    let gamma_shifted_rad = gamma_shifted_mid * ln_gamma_rad;
321    let mut gamma_ball = Ball::new(gamma_shifted_mid, gamma_shifted_rad);
322
323    // Divide back by the shifted factors.
324    for k in 0..shift {
325        let factor = Ball::new(x.mid + k as f64, x.rad);
326        gamma_ball = gamma_ball.div(&factor)?;
327    }
328
329    Some(gamma_ball)
330}
331
332// ─────────────────────────────────────────────────────────────────────────────
333// Validation helper
334// ─────────────────────────────────────────────────────────────────────────────
335
336/// Validate that a computed floating-point value is contained in a ball.
337///
338/// Returns `true` when `ball.contains(computed)`.
339#[inline]
340pub fn validate(computed: f64, ball: Ball) -> bool {
341    ball.contains(computed)
342}
343
344// ─────────────────────────────────────────────────────────────────────────────
345// Tests
346// ─────────────────────────────────────────────────────────────────────────────
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn test_ball_add_sub() {
354        let a = Ball::new(3.0, 0.1);
355        let b = Ball::new(2.0, 0.05);
356        let sum = a + b;
357        assert!(sum.contains(5.0));
358        assert!(sum.rad >= 0.15); // at least the propagated radius
359
360        let diff = a - b;
361        assert!(diff.contains(1.0));
362        assert!(diff.rad >= 0.15);
363    }
364
365    #[test]
366    fn test_ball_mul_propagation() {
367        // [2 ± 0] * [3 ± 0] = [6 ± 0] (exact)
368        let a = Ball::from_exact(2.0);
369        let b = Ball::from_exact(3.0);
370        let prod = a * b;
371        assert!(prod.contains(6.0));
372        assert!(prod.rad < 1e-12);
373
374        // [2 ± 0.5] * [3 ± 0.5] should contain 6 and have radius >= |2|*0.5 + |3|*0.5 = 2.5
375        let a2 = Ball::new(2.0, 0.5);
376        let b2 = Ball::new(3.0, 0.5);
377        let prod2 = a2 * b2;
378        assert!(prod2.contains(6.0));
379        assert!(prod2.rad >= 2.5);
380    }
381
382    #[test]
383    fn test_ball_sin_contains() {
384        // sin(pi/6) = 0.5 exactly.
385        let x = Ball::new(std::f64::consts::PI / 6.0, 1e-8);
386        let s = ball_sin(x);
387        let true_val = (std::f64::consts::PI / 6.0).sin();
388        assert!(
389            s.contains(true_val),
390            "ball_sin should contain true sin value; ball=[{}, {}], true={}",
391            s.lo(),
392            s.hi(),
393            true_val
394        );
395    }
396
397    #[test]
398    fn test_ball_gamma_basic() {
399        // Gamma(5) = 4! = 24.
400        let x = Ball::new(5.0, 1e-10);
401        let g = ball_gamma(x).expect("ball_gamma(5) should not be None");
402        assert!(
403            g.contains(24.0),
404            "ball_gamma(5) should contain 24.0; ball=[{}, {}]",
405            g.lo(),
406            g.hi()
407        );
408    }
409
410    #[test]
411    fn test_ball_gamma_one() {
412        // Gamma(1) = 1.
413        // Use a small but nonzero radius to account for the Stirling error at x=1.
414        let x = Ball::new(1.0, 1e-6);
415        let g = ball_gamma(x).expect("ball_gamma(1) should not be None");
416        assert!(
417            g.contains(1.0),
418            "ball_gamma(1) should contain 1.0; ball=[{}, {}]",
419            g.lo(),
420            g.hi()
421        );
422    }
423
424    #[test]
425    fn test_ball_validate() {
426        let b = Ball::new(2.71, 0.02);
427        assert!(validate(2.71, b)); // midpoint
428        assert!(validate(2.72, b)); // lo side (2.71 - 0.02 = 2.69 <= 2.72)
429        assert!(validate(2.70, b)); // hi side (2.70 <= 2.71 + 0.02 = 2.73)
430        assert!(!validate(2.68, b)); // outside lo (2.68 < 2.69)
431        assert!(!validate(2.74, b)); // outside hi (2.74 > 2.73)
432    }
433
434    #[test]
435    fn test_ball_div_by_zero() {
436        // Dividing by a ball that contains zero must return None.
437        let a = Ball::new(5.0, 0.1);
438        let zero_ball = Ball::new(0.0, 1.0); // contains 0
439        assert!(a.div(&zero_ball).is_none());
440
441        // Dividing by a ball that does NOT contain zero should succeed.
442        let safe_div = Ball::new(2.0, 0.1);
443        let result = a.div(&safe_div);
444        assert!(result.is_some());
445        let r = result.expect("should divide safely");
446        assert!(r.contains(2.5));
447    }
448
449    #[test]
450    fn test_ball_sqrt() {
451        let four = Ball::new(4.0, 1e-10);
452        let root = four.sqrt().expect("sqrt(4) should not be None");
453        assert!(root.contains(2.0));
454
455        // Negative interval should return None.
456        let neg = Ball::new(-1.0, 0.1);
457        assert!(neg.sqrt().is_none());
458    }
459
460    #[test]
461    fn test_ball_exp_contains() {
462        let x = Ball::new(1.0, 1e-9);
463        let e_ball = ball_exp(x);
464        assert!(e_ball.contains(std::f64::consts::E));
465    }
466
467    #[test]
468    fn test_ball_ln_contains() {
469        let x = Ball::new(std::f64::consts::E, 1e-10);
470        let ln_ball = ball_ln(x).expect("ln(e) should not be None");
471        assert!(ln_ball.contains(1.0));
472    }
473
474    #[test]
475    fn test_ball_powi() {
476        let x = Ball::new(2.0, 0.0);
477        let p = x.powi(10);
478        assert!(p.contains(1024.0));
479    }
480
481    #[test]
482    fn test_ball_from_interval() {
483        let b = Ball::from_interval(1.0, 3.0).expect("valid interval");
484        assert_eq!(b.mid, 2.0);
485        assert_eq!(b.rad, 1.0);
486        assert!(b.contains(1.0));
487        assert!(b.contains(3.0));
488
489        // Invalid interval: lo > hi
490        assert!(Ball::from_interval(3.0, 1.0).is_none());
491    }
492}