axiolid_exact/
interval.rs

1//! Outward-rounded interval arithmetic: the fast tier.
2//!
3//! Every result bound is the round-to-nearest `f64` result moved one step
4//! outward. Round-to-nearest errs by at most half the gap to the adjacent
5//! float in the direction of the error, so one step (`next_down` /
6//! `next_up`) always covers the true value. This holds in the subnormal
7//! range, at binade boundaries (where the gap below is half the gap above,
8//! and so is the error), and on overflow (`+inf` rounded from a finite true
9//! value steps down to `f64::MAX`, which is still below it).
10//!
11//! NaN never escapes as a bound: `f64::min`/`max` silently discard NaN,
12//! which would turn "unknown" into a confident wrong bound. Any NaN
13//! collapses the interval to the whole line, which is always sound and
14//! never decides a sign.
15
16use axiolid_guarantees::Sign;
17
18use crate::arith::Arith;
19
20/// A closed interval `[lo, hi]` known to contain the true value.
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub struct Interval {
23    lo: f64,
24    hi: f64,
25}
26
27impl Interval {
28    /// The whole extended real line: contains every value, decides nothing.
29    pub const WHOLE: Self = Self {
30        lo: f64::NEG_INFINITY,
31        hi: f64::INFINITY,
32    };
33
34    /// The degenerate interval `[value, value]`, exact for finite input.
35    #[must_use]
36    pub fn point(value: f64) -> Self {
37        if value.is_finite() {
38            Self {
39                lo: value,
40                hi: value,
41            }
42        } else {
43            Self::WHOLE
44        }
45    }
46
47    /// Lower bound.
48    #[must_use]
49    pub const fn lo(self) -> f64 {
50        self.lo
51    }
52
53    /// Upper bound.
54    #[must_use]
55    pub const fn hi(self) -> f64 {
56        self.hi
57    }
58
59    /// Whether `value` lies in `[lo, hi]`.
60    #[must_use]
61    pub fn contains(self, value: f64) -> bool {
62        self.lo <= value && value <= self.hi
63    }
64
65    /// `[lo, hi]` as given. Crate-private: callers must have proven the
66    /// bounds contain the value, which only this crate's code can do.
67    pub(crate) fn from_bounds(lo: f64, hi: f64) -> Self {
68        if lo.is_nan() || hi.is_nan() || lo > hi {
69            return Self::WHOLE;
70        }
71        Self { lo, hi }
72    }
73
74    /// A sound enclosure of `self / divisor`, or [`Interval::WHOLE`] when
75    /// the divisor may be zero.
76    ///
77    /// Each bound is one IEEE division (correctly rounded), widened one
78    /// step outward. Lets callers keep a cheap per-point box for values of
79    /// the form `numerator / weight`.
80    #[must_use]
81    pub fn quotient(self, divisor: Self) -> Self {
82        if divisor.lo <= 0.0 && divisor.hi >= 0.0 || divisor.lo.is_nan() || divisor.hi.is_nan() {
83            return Self::WHOLE;
84        }
85        let q = [
86            self.lo / divisor.lo,
87            self.lo / divisor.hi,
88            self.hi / divisor.lo,
89            self.hi / divisor.hi,
90        ];
91        if q.iter().any(|v| v.is_nan()) {
92            return Self::WHOLE;
93        }
94        let lo = q.iter().copied().fold(f64::INFINITY, f64::min);
95        let hi = q.iter().copied().fold(f64::NEG_INFINITY, f64::max);
96        Self::outward(lo, hi)
97    }
98
99    /// Whether the two intervals share no point.
100    #[must_use]
101    pub fn disjoint(self, other: Self) -> bool {
102        self.hi < other.lo || other.hi < self.lo
103    }
104
105    /// Bounds already known to be sound, widened one step outward.
106    fn outward(lo: f64, hi: f64) -> Self {
107        if lo.is_nan() || hi.is_nan() {
108            return Self::WHOLE;
109        }
110        Self {
111            lo: lo.next_down(),
112            hi: hi.next_up(),
113        }
114    }
115}
116
117impl Arith for Interval {
118    fn from_f64(value: f64) -> Self {
119        Self::point(value)
120    }
121
122    fn from_dyadic(value: &crate::dyadic::Dyadic) -> Self {
123        value.enclosure()
124    }
125
126    fn add(&self, other: &Self) -> Self {
127        Self::outward(self.lo + other.lo, self.hi + other.hi)
128    }
129
130    fn sub(&self, other: &Self) -> Self {
131        Self::outward(self.lo - other.hi, self.hi - other.lo)
132    }
133
134    fn mul(&self, other: &Self) -> Self {
135        let products = [
136            self.lo * other.lo,
137            self.lo * other.hi,
138            self.hi * other.lo,
139            self.hi * other.hi,
140        ];
141        // 0 * inf is NaN; see the module note on why NaN must not reach
142        // min/max.
143        if products.iter().any(|p| p.is_nan()) {
144            return Self::WHOLE;
145        }
146        let lo = products.iter().copied().fold(f64::INFINITY, f64::min);
147        let hi = products.iter().copied().fold(f64::NEG_INFINITY, f64::max);
148        Self::outward(lo, hi)
149    }
150
151    fn neg(&self) -> Self {
152        // Negation is exact in IEEE arithmetic: no widening needed.
153        Self {
154            lo: -self.hi,
155            hi: -self.lo,
156        }
157    }
158
159    fn sign(&self) -> Option<Sign> {
160        if self.lo > 0.0 {
161            Some(Sign::Positive)
162        } else if self.hi < 0.0 {
163            Some(Sign::Negative)
164        } else if self.lo == 0.0 && self.hi == 0.0 {
165            // [0, 0] contains only zero. It arises from literal zero inputs
166            // (and exact negation), never from a widened operation.
167            Some(Sign::Zero)
168        } else {
169            None
170        }
171    }
172
173    fn square(&self) -> Self {
174        // Tighter than mul(self, self): a square is never negative, so an
175        // interval straddling zero squares to [0, max].
176        if self.lo >= 0.0 || self.hi <= 0.0 {
177            return self.mul(self);
178        }
179        let top = (self.lo * self.lo).max(self.hi * self.hi);
180        if top.is_nan() {
181            return Self::WHOLE;
182        }
183        Self {
184            lo: 0.0,
185            hi: top.next_up(),
186        }
187    }
188
189    fn sqrt_enclosure(&self) -> Option<Self> {
190        // IEEE sqrt is correctly rounded, so one step outward covers the
191        // true root. A lower bound below zero is NOT clamped: the radicand
192        // might truly be negative, where the value is undefined and the
193        // exact tier says so; a clamped root would let the filter report a
194        // sign for a non-real value. Such cases fall through to the exact
195        // tier (a radicand of exactly zero, as at tangency, is one).
196        // NaN compares false, so it is refused here too.
197        if self.lo.is_nan() || self.lo < 0.0 {
198            return None;
199        }
200        Some(Self {
201            lo: self.lo.sqrt().next_down().max(0.0),
202            hi: self.hi.sqrt().next_up(),
203        })
204    }
205}