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}