Skip to main content

ocas_poly/
roots.rs

1//! Real root isolation and numerical root approximation.
2//!
3//! Uses Sturm sequences for exact real-root counting and isolation,
4//! with bisection for refinement. For high-precision refinement, the
5//! optional `mpfr` backend can be used.
6
7use std::fmt::Display;
8
9use ocas_domain::EuclideanDomain;
10
11use crate::dense::DenseUnivariatePolynomial;
12
13/// A real interval known to contain exactly one root.
14#[derive(Debug, Clone, PartialEq)]
15pub struct RootInterval {
16    /// Lower bound of the interval.
17    pub low: f64,
18    /// Upper bound of the interval.
19    pub high: f64,
20}
21
22impl<D: EuclideanDomain> DenseUnivariatePolynomial<D>
23where
24    D::Element: Display,
25{
26    /// Compute the Sturm sequence for this polynomial.
27    ///
28    /// The Sturm sequence is: p0 = p, p1 = p', p_{i+1} = -rem(p_{i-1}, p_i).
29    /// The number of sign changes at x gives the number of real roots > x.
30    pub fn sturm_sequence(&self) -> Vec<Self> {
31        let mut seq = Vec::new();
32        if self.is_zero() {
33            return seq;
34        }
35
36        seq.push(self.clone());
37        let deriv = self.derivative();
38        if deriv.is_zero() {
39            return seq;
40        }
41        seq.push(deriv);
42
43        loop {
44            let a = &seq[seq.len() - 2];
45            let b = &seq[seq.len() - 1];
46            if b.is_zero() {
47                break;
48            }
49            // Compute pseudo-remainder and negate.
50            let rem = match a.pseudo_remainder(b) {
51                Some(r) => r,
52                None => break,
53            };
54            if rem.is_zero() {
55                break;
56            }
57            // Negate: p_{i+1} = -rem(p_{i-1}, p_i)
58            seq.push(rem.neg());
59        }
60
61        seq
62    }
63
64    /// Evaluate this polynomial at `x` as a floating-point value.
65    ///
66    /// Uses Horner's method with f64 arithmetic. For exact evaluation,
67    /// use `eval()` with domain elements.
68    pub fn eval_f64(&self, x: f64) -> f64 {
69        let mut result = 0.0;
70        for coeff in self.coeffs().iter().rev() {
71            result = result * x + coeff_value(coeff);
72        }
73        result
74    }
75
76    /// Count the number of distinct real roots of this polynomial.
77    ///
78    /// Uses Sturm's theorem: count roots in (-∞, +∞).
79    pub fn count_real_roots(&self) -> usize {
80        let seq = self.sturm_sequence();
81        if seq.len() < 2 {
82            return 0;
83        }
84        let neg_inf = count_sign_changes_at_infinity(&seq, true);
85        let pos_inf = count_sign_changes_at_infinity(&seq, false);
86        neg_inf.saturating_sub(pos_inf)
87    }
88
89    /// Isolate real roots: return a list of intervals, each containing
90    /// exactly one real root.
91    ///
92    /// Uses bisection with Sturm-based counting to find intervals.
93    pub fn isolate_real_roots(&self) -> Vec<RootInterval> {
94        let seq = self.sturm_sequence();
95        if seq.len() < 2 {
96            return vec![];
97        }
98
99        let total_roots = self.count_real_roots();
100        if total_roots == 0 {
101            return vec![];
102        }
103
104        // Find a bounding interval [-M, M] that contains all real roots.
105        let m = root_bound(self);
106        let mut intervals = Vec::new();
107        let mut stack = vec![(-m, m)];
108
109        while let Some((lo, hi)) = stack.pop() {
110            if intervals.len() >= total_roots {
111                break;
112            }
113
114            let lo_signs = count_sign_changes(&seq, lo);
115            let hi_signs = count_sign_changes(&seq, hi);
116            let count = lo_signs.saturating_sub(hi_signs);
117
118            if count == 0 {
119                continue;
120            }
121            if count == 1 && (hi - lo) < 1e-10 {
122                intervals.push(RootInterval { low: lo, high: hi });
123                continue;
124            }
125            if hi - lo < 1e-12 {
126                if count == 1 {
127                    intervals.push(RootInterval { low: lo, high: hi });
128                }
129                continue;
130            }
131
132            let mid = (lo + hi) / 2.0;
133            stack.push((lo, mid));
134            stack.push((mid, hi));
135        }
136
137        intervals
138    }
139
140    /// Refine a root interval using bisection to the given tolerance.
141    pub fn refine_root(&self, interval: &RootInterval, tol: f64) -> RootInterval {
142        let mut lo = interval.low;
143        let mut hi = interval.high;
144        let f_lo = self.eval_f64(lo);
145
146        if f_lo.abs() < 1e-15 {
147            return RootInterval { low: lo, high: lo };
148        }
149
150        while hi - lo > tol {
151            let mid = (lo + hi) / 2.0;
152            let f_mid = self.eval_f64(mid);
153            if f_mid.abs() < 1e-15 {
154                return RootInterval {
155                    low: mid,
156                    high: mid,
157                };
158            }
159            if f_lo * f_mid < 0.0 {
160                hi = mid;
161            } else {
162                lo = mid;
163            }
164        }
165
166        RootInterval { low: lo, high: hi }
167    }
168}
169
170/// Count sign changes in the Sturm sequence at ±∞.
171fn count_sign_changes_at_infinity<D: EuclideanDomain>(
172    seq: &[DenseUnivariatePolynomial<D>],
173    at_neg_inf: bool,
174) -> usize
175where
176    D::Element: Display,
177{
178    let vals: Vec<f64> = seq
179        .iter()
180        .map(|p| {
181            if p.is_zero() {
182                return 0.0;
183            }
184            let deg = p.degree().unwrap_or(0);
185            let lc = coeff_value(p.leading_coeff().unwrap());
186            // At +∞: sign of leading coefficient
187            // At -∞: sign depends on degree parity
188            if at_neg_inf {
189                if deg % 2 == 0 { lc } else { -lc }
190            } else {
191                lc
192            }
193        })
194        .collect();
195    count_sign_changes_in_vals(&vals)
196}
197
198/// Evaluate the Sturm sequence at `x` and count sign changes.
199fn count_sign_changes<D: EuclideanDomain>(seq: &[DenseUnivariatePolynomial<D>], x: f64) -> usize
200where
201    D::Element: Display,
202{
203    let vals: Vec<f64> = seq.iter().map(|p| p.eval_f64(x)).collect();
204    count_sign_changes_in_vals(&vals)
205}
206
207fn count_sign_changes_in_vals(vals: &[f64]) -> usize {
208    let mut count = 0;
209    let mut prev_sign: Option<bool> = None;
210    for &v in vals {
211        if v == 0.0 {
212            continue;
213        }
214        let sign = v > 0.0;
215        if let Some(p) = prev_sign
216            && p != sign
217        {
218            count += 1;
219        }
220        prev_sign = Some(sign);
221    }
222    count
223}
224
225/// Compute a bound M such that all real roots lie in [-M, M].
226fn root_bound<D: EuclideanDomain>(p: &DenseUnivariatePolynomial<D>) -> f64
227where
228    D::Element: Display,
229{
230    if p.is_zero() || p.degree().is_none() {
231        return 1.0;
232    }
233    let coeffs = p.coeffs();
234    let lc = coeff_value(coeffs.last().unwrap()).abs();
235    let mut max_abs = 0.0f64;
236    for c in &coeffs[..coeffs.len() - 1] {
237        let v = coeff_value(c).abs();
238        if v > max_abs {
239            max_abs = v;
240        }
241    }
242    1.0 + max_abs / lc.max(1e-10)
243}
244
245/// Convert a domain element to f64 for numerical evaluation.
246fn coeff_value(elem: &(impl Display + ?Sized)) -> f64 {
247    let s = elem.to_string();
248    s.trim()
249        .parse::<f64>()
250        .unwrap_or_else(|_| s.trim().parse::<i64>().map(|v| v as f64).unwrap_or(0.0))
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use ocas_domain::{Integer, IntegerDomain};
257
258    fn i(n: i64) -> Integer {
259        Integer::from(n)
260    }
261
262    #[test]
263    fn count_roots_x2_minus_1() {
264        let d = IntegerDomain;
265        let p = DenseUnivariatePolynomial::from_coeffs(d, vec![i(-1), i(0), i(1)]);
266        assert_eq!(p.count_real_roots(), 2);
267    }
268
269    #[test]
270    fn count_roots_x2_plus_1() {
271        let d = IntegerDomain;
272        let p = DenseUnivariatePolynomial::from_coeffs(d, vec![i(1), i(0), i(1)]);
273        assert_eq!(p.count_real_roots(), 0);
274    }
275
276    #[test]
277    fn count_roots_perfect_square() {
278        let d = IntegerDomain;
279        // (x+1)^2 = x^2 + 2x + 1 has one distinct real root at x = -1
280        let p = DenseUnivariatePolynomial::from_coeffs(d, vec![i(1), i(2), i(1)]);
281        assert_eq!(p.count_real_roots(), 1);
282    }
283
284    #[test]
285    fn isolate_roots_x2_minus_2() {
286        let d = IntegerDomain;
287        let p = DenseUnivariatePolynomial::from_coeffs(d, vec![i(-2), i(0), i(1)]);
288        let intervals = p.isolate_real_roots();
289        assert_eq!(intervals.len(), 2);
290        // Refine one root to verify it's near sqrt(2) ≈ 1.414
291        let refined = p.refine_root(&intervals[1], 1e-6);
292        let approx = (refined.low + refined.high) / 2.0;
293        assert!((approx.abs() - std::f64::consts::SQRT_2).abs() < 0.01);
294    }
295
296    #[test]
297    fn sturm_sequence_length() {
298        let d = IntegerDomain;
299        let p = DenseUnivariatePolynomial::from_coeffs(d, vec![i(-1), i(0), i(1)]);
300        let seq = p.sturm_sequence();
301        assert!(seq.len() >= 2);
302    }
303}