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 num_bigint::{BigInt, Sign};
10use num_rational::BigRational;
11use num_traits::{ToPrimitive, Zero};
12
13use ocas_domain::EuclideanDomain;
14
15use crate::dense::DenseUnivariatePolynomial;
16
17/// A real interval known to contain exactly one root.
18#[derive(Debug, Clone, PartialEq)]
19pub struct RootInterval {
20    /// Lower bound of the interval.
21    pub low: f64,
22    /// Upper bound of the interval.
23    pub high: f64,
24}
25
26impl<D: EuclideanDomain> DenseUnivariatePolynomial<D>
27where
28    D::Element: Display,
29{
30    /// Compute the Sturm sequence for this polynomial.
31    ///
32    /// The Sturm sequence is: p0 = p, p1 = p', p_{i+1} = -rem(p_{i-1}, p_i).
33    /// The number of sign changes at x gives the number of real roots > x.
34    pub fn sturm_sequence(&self) -> Vec<Self> {
35        let mut seq = Vec::new();
36        if self.is_zero() {
37            return seq;
38        }
39
40        seq.push(self.clone());
41        let deriv = self.derivative();
42        if deriv.is_zero() {
43            return seq;
44        }
45        seq.push(deriv);
46
47        loop {
48            let a = &seq[seq.len() - 2];
49            let b = &seq[seq.len() - 1];
50            if b.is_zero() {
51                break;
52            }
53            // Compute pseudo-remainder and negate.
54            let rem = match a.pseudo_remainder(b) {
55                Some(r) => r,
56                None => break,
57            };
58            if rem.is_zero() {
59                break;
60            }
61            // Negate: p_{i+1} = -rem(p_{i-1}, p_i)
62            seq.push(rem.neg());
63        }
64
65        seq
66    }
67
68    /// Evaluate this polynomial at `x` as a floating-point value.
69    ///
70    /// Uses Horner's method with f64 arithmetic. For exact evaluation,
71    /// use `eval()` with domain elements.
72    pub fn eval_f64(&self, x: f64) -> f64 {
73        let mut result = 0.0;
74        for coeff in self.coeffs().iter().rev() {
75            result = result * x + coeff_value(coeff);
76        }
77        result
78    }
79
80    /// Count the number of distinct real roots of this polynomial.
81    ///
82    /// Uses Sturm's theorem: count roots in (-∞, +∞).
83    pub fn count_real_roots(&self) -> usize {
84        let seq = self.sturm_sequence();
85        if seq.len() < 2 {
86            return 0;
87        }
88        let neg_inf = count_sign_changes_at_infinity(&seq, true);
89        let pos_inf = count_sign_changes_at_infinity(&seq, false);
90        neg_inf.saturating_sub(pos_inf)
91    }
92
93    /// Isolate real roots: return a list of intervals, each containing
94    /// exactly one real root.
95    ///
96    /// Uses bisection with Sturm-based counting to find intervals.
97    /// Sign evaluation is exact (dyadic-rational bisection points with
98    /// big-integer arithmetic) whenever the coefficients parse as
99    /// rationals; ill-conditioned polynomials like the expanded Wilkinson
100    /// `∏(x−k)` lose roots under f64 sign evaluation. Falls back to the
101    /// f64 path for coefficients without a rational representation.
102    pub fn isolate_real_roots(&self) -> Vec<RootInterval> {
103        let seq = self.sturm_sequence();
104        if seq.len() < 2 {
105            return vec![];
106        }
107
108        let total_roots = self.count_real_roots();
109        if total_roots == 0 {
110            return vec![];
111        }
112
113        // Find a bounding interval [-M, M] that contains all real roots.
114        let m = root_bound(self);
115        if let Some(exact) = ExactSturm::prepare(&seq) {
116            return self.isolate_exact(&exact, m, total_roots);
117        }
118        self.isolate_f64(&seq, m, total_roots)
119    }
120
121    /// f64 bisection fallback (pre-0.27 behaviour), for Sturm sequences
122    /// whose coefficients are not exactly representable as rationals.
123    fn isolate_f64(
124        &self,
125        seq: &[DenseUnivariatePolynomial<D>],
126        m: f64,
127        total_roots: usize,
128    ) -> Vec<RootInterval> {
129        let mut intervals = Vec::new();
130        let mut stack = vec![(-m, m)];
131
132        while let Some((lo, hi)) = stack.pop() {
133            if intervals.len() >= total_roots {
134                break;
135            }
136
137            let lo_signs = count_sign_changes(seq, lo);
138            let hi_signs = count_sign_changes(seq, hi);
139            let count = lo_signs.saturating_sub(hi_signs);
140
141            if count == 0 {
142                continue;
143            }
144            if count == 1 && (hi - lo) < 1e-10 {
145                intervals.push(RootInterval { low: lo, high: hi });
146                continue;
147            }
148            if hi - lo < 1e-12 {
149                if count == 1 {
150                    intervals.push(RootInterval { low: lo, high: hi });
151                }
152                continue;
153            }
154
155            let mid = (lo + hi) / 2.0;
156            stack.push((lo, mid));
157            stack.push((mid, hi));
158        }
159
160        intervals
161    }
162
163    /// Exact bisection: endpoints are dyadics `m · 2⁻ᵏ`, sign counts are
164    /// computed with big-integer arithmetic (no f64 rounding).
165    fn isolate_exact(&self, seq: &ExactSturm, m: f64, total_roots: usize) -> Vec<RootInterval> {
166        let bound = BigInt::from(m.ceil() as i64);
167        let mut intervals = Vec::new();
168        // Stack entries: (lo_m, hi_m, k) representing [lo_m·2⁻ᵏ, hi_m·2⁻ᵏ].
169        let mut stack = vec![(-&bound, bound, 0u32)];
170
171        while let Some((lo_m, hi_m, k)) = stack.pop() {
172            if intervals.len() >= total_roots {
173                break;
174            }
175
176            let lo_signs = seq.count_at(&lo_m, k);
177            let hi_signs = seq.count_at(&hi_m, k);
178            let count = lo_signs.saturating_sub(hi_signs);
179
180            if count == 0 {
181                continue;
182            }
183            // Width as f64 is only used for the termination thresholds,
184            // never for sign decisions.
185            let width = (&hi_m - &lo_m).to_f64().unwrap_or(f64::INFINITY) / 2.0f64.powi(k as i32);
186            let low_f = dyadic_f64(&lo_m, k);
187            let high_f = dyadic_f64(&hi_m, k);
188            if count == 1 && width < 1e-10 {
189                intervals.push(RootInterval {
190                    low: low_f,
191                    high: high_f,
192                });
193                continue;
194            }
195            if width < 1e-12 {
196                if count == 1 {
197                    intervals.push(RootInterval {
198                        low: low_f,
199                        high: high_f,
200                    });
201                }
202                continue;
203            }
204
205            // mid = (lo + hi)/2; all endpoints move to scale k+1.
206            let lo2 = &lo_m << 1usize;
207            let hi2 = &hi_m << 1usize;
208            let mid = &lo_m + &hi_m;
209            stack.push((lo2, mid.clone(), k + 1));
210            stack.push((mid, hi2, k + 1));
211        }
212
213        intervals
214    }
215
216    /// Refine a root interval using bisection to the given tolerance.
217    pub fn refine_root(&self, interval: &RootInterval, tol: f64) -> RootInterval {
218        let mut lo = interval.low;
219        let mut hi = interval.high;
220        let f_lo = self.eval_f64(lo);
221
222        if f_lo.abs() < 1e-15 {
223            return RootInterval { low: lo, high: lo };
224        }
225
226        while hi - lo > tol {
227            let mid = (lo + hi) / 2.0;
228            let f_mid = self.eval_f64(mid);
229            if f_mid.abs() < 1e-15 {
230                return RootInterval {
231                    low: mid,
232                    high: mid,
233                };
234            }
235            if f_lo * f_mid < 0.0 {
236                hi = mid;
237            } else {
238                lo = mid;
239            }
240        }
241
242        RootInterval { low: lo, high: hi }
243    }
244}
245
246/// A Sturm sequence pre-scaled to integer coefficients for exact dyadic
247/// evaluation (per polynomial, coefficients are `cᵢ·lcm(denoms)`).
248struct ExactSturm {
249    polys: Vec<Vec<BigInt>>,
250}
251
252impl ExactSturm {
253    /// Clear denominators of every Sturm polynomial. Returns `None` when a
254    /// coefficient has no rational text form.
255    fn prepare<D: EuclideanDomain>(seq: &[DenseUnivariatePolynomial<D>]) -> Option<Self>
256    where
257        D::Element: Display,
258    {
259        let mut polys = Vec::with_capacity(seq.len());
260        for p in seq {
261            let mut coeffs = Vec::with_capacity(p.coeffs().len());
262            let mut lcm = BigInt::from(1);
263            for c in p.coeffs() {
264                let r = coeff_to_bigrational(c)?;
265                lcm = bigint_lcm(&lcm, r.denom());
266                coeffs.push(r);
267            }
268            let scale = BigRational::from_integer(lcm);
269            polys.push(coeffs.iter().map(|c| (c * &scale).to_integer()).collect());
270        }
271        Some(Self { polys })
272    }
273
274    /// Exact sign of every Sturm polynomial at `m·2⁻ᵏ`: for a degree-`n`
275    /// polynomial, `p(m·2⁻ᵏ) = 2⁻ᵏⁿ·L⁻¹·Σ aᵢ·mⁱ·2ᵏ⁽ⁿ⁻ⁱ⁾` and the scale
276    /// factors are positive, so the sum's sign is the value's sign.
277    fn count_at(&self, m: &BigInt, k: u32) -> usize {
278        let mut count = 0;
279        let mut prev: Option<bool> = None;
280        for icoeffs in &self.polys {
281            let sign = eval_sign_dyadic(icoeffs, m, k);
282            if sign == 0 {
283                continue;
284            }
285            let positive = sign > 0;
286            if let Some(p) = prev
287                && p != positive
288            {
289                count += 1;
290            }
291            prev = Some(positive);
292        }
293        count
294    }
295}
296
297/// Sign of `Σ aᵢ·mⁱ·2ᵏ⁽ⁿ⁻ⁱ⁾` for integer coefficients `aᵢ` (ascending).
298fn eval_sign_dyadic(coeffs: &[BigInt], m: &BigInt, k: u32) -> i8 {
299    let n = coeffs.len().saturating_sub(1);
300    let mut s = BigInt::zero();
301    for (i, a) in coeffs.iter().enumerate() {
302        if a.is_zero() {
303            continue;
304        }
305        let mut t = a * m.pow(i as u32);
306        t <<= k as usize * (n - i);
307        s += t;
308    }
309    match s.sign() {
310        Sign::Plus => 1,
311        Sign::Minus => -1,
312        Sign::NoSign => 0,
313    }
314}
315
316/// Convert a dyadic `m·2⁻ᵏ` to f64 (display/intervals only).
317fn dyadic_f64(m: &BigInt, k: u32) -> f64 {
318    m.to_f64().unwrap_or(f64::NAN) / 2.0f64.powi(k as i32)
319}
320
321/// Parse a domain element's display text as an exact rational
322/// ("n", "-n", or "n/d").
323fn coeff_to_bigrational(elem: &(impl Display + ?Sized)) -> Option<BigRational> {
324    elem.to_string().trim().parse::<BigRational>().ok()
325}
326
327/// lcm for positive big integers (Euclid).
328fn bigint_lcm(a: &BigInt, b: &BigInt) -> BigInt {
329    let mut x = a.clone();
330    let mut y = b.clone();
331    while !y.is_zero() {
332        let r = x % &y;
333        x = y;
334        y = r;
335    }
336    if x.is_zero() {
337        return BigInt::from(1);
338    }
339    (a / &x) * b
340}
341
342/// Count sign changes in the Sturm sequence at ±∞.
343fn count_sign_changes_at_infinity<D: EuclideanDomain>(
344    seq: &[DenseUnivariatePolynomial<D>],
345    at_neg_inf: bool,
346) -> usize
347where
348    D::Element: Display,
349{
350    let vals: Vec<f64> = seq
351        .iter()
352        .map(|p| {
353            if p.is_zero() {
354                return 0.0;
355            }
356            let deg = p.degree().unwrap_or(0);
357            let lc = coeff_value(p.leading_coeff().unwrap());
358            // At +∞: sign of leading coefficient
359            // At -∞: sign depends on degree parity
360            if at_neg_inf {
361                if deg % 2 == 0 { lc } else { -lc }
362            } else {
363                lc
364            }
365        })
366        .collect();
367    count_sign_changes_in_vals(&vals)
368}
369
370/// Evaluate the Sturm sequence at `x` and count sign changes.
371fn count_sign_changes<D: EuclideanDomain>(seq: &[DenseUnivariatePolynomial<D>], x: f64) -> usize
372where
373    D::Element: Display,
374{
375    let vals: Vec<f64> = seq.iter().map(|p| p.eval_f64(x)).collect();
376    count_sign_changes_in_vals(&vals)
377}
378
379fn count_sign_changes_in_vals(vals: &[f64]) -> usize {
380    let mut count = 0;
381    let mut prev_sign: Option<bool> = None;
382    for &v in vals {
383        if v == 0.0 {
384            continue;
385        }
386        let sign = v > 0.0;
387        if let Some(p) = prev_sign
388            && p != sign
389        {
390            count += 1;
391        }
392        prev_sign = Some(sign);
393    }
394    count
395}
396
397/// Compute a bound M such that all real roots lie in [-M, M].
398fn root_bound<D: EuclideanDomain>(p: &DenseUnivariatePolynomial<D>) -> f64
399where
400    D::Element: Display,
401{
402    if p.is_zero() || p.degree().is_none() {
403        return 1.0;
404    }
405    let coeffs = p.coeffs();
406    let lc = coeff_value(coeffs.last().unwrap()).abs();
407    let mut max_abs = 0.0f64;
408    for c in &coeffs[..coeffs.len() - 1] {
409        let v = coeff_value(c).abs();
410        if v > max_abs {
411            max_abs = v;
412        }
413    }
414    1.0 + max_abs / lc.max(1e-10)
415}
416
417/// Convert a domain element to f64 for numerical evaluation.
418fn coeff_value(elem: &(impl Display + ?Sized)) -> f64 {
419    let s = elem.to_string();
420    let trimmed = s.trim();
421    // Try direct f64 parse first.
422    if let Ok(v) = trimmed.parse::<f64>() {
423        return v;
424    }
425    // Try integer parse.
426    if let Ok(v) = trimmed.parse::<i64>() {
427        return v as f64;
428    }
429    // Try rational format "n/d".
430    if let Some((num_str, den_str)) = trimmed.split_once('/')
431        && let (Ok(n), Ok(d)) = (num_str.trim().parse::<f64>(), den_str.trim().parse::<f64>())
432        && d != 0.0
433    {
434        return n / d;
435    }
436    0.0
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use ocas_domain::{Integer, IntegerDomain};
443
444    fn i(n: i64) -> Integer {
445        Integer::from(n)
446    }
447
448    #[test]
449    fn count_roots_x2_minus_1() {
450        let d = IntegerDomain;
451        let p = DenseUnivariatePolynomial::from_coeffs(d, vec![i(-1), i(0), i(1)]);
452        assert_eq!(p.count_real_roots(), 2);
453    }
454
455    #[test]
456    fn count_roots_x2_plus_1() {
457        let d = IntegerDomain;
458        let p = DenseUnivariatePolynomial::from_coeffs(d, vec![i(1), i(0), i(1)]);
459        assert_eq!(p.count_real_roots(), 0);
460    }
461
462    #[test]
463    fn count_roots_perfect_square() {
464        let d = IntegerDomain;
465        // (x+1)^2 = x^2 + 2x + 1 has one distinct real root at x = -1
466        let p = DenseUnivariatePolynomial::from_coeffs(d, vec![i(1), i(2), i(1)]);
467        assert_eq!(p.count_real_roots(), 1);
468    }
469
470    #[test]
471    fn isolate_roots_x2_minus_2() {
472        let d = IntegerDomain;
473        let p = DenseUnivariatePolynomial::from_coeffs(d, vec![i(-2), i(0), i(1)]);
474        let intervals = p.isolate_real_roots();
475        assert_eq!(intervals.len(), 2);
476        // Refine one root to verify it's near sqrt(2) ≈ 1.414
477        let refined = p.refine_root(&intervals[1], 1e-6);
478        let approx = (refined.low + refined.high) / 2.0;
479        assert!((approx.abs() - std::f64::consts::SQRT_2).abs() < 0.01);
480    }
481
482    #[test]
483    fn sturm_sequence_length() {
484        let d = IntegerDomain;
485        let p = DenseUnivariatePolynomial::from_coeffs(d, vec![i(-1), i(0), i(1)]);
486        let seq = p.sturm_sequence();
487        assert!(seq.len() >= 2);
488    }
489}