Skip to main content

oxiz_math/polynomial/
root_isolation.rs

1//! Root Isolation for Univariate Polynomials.
2//!
3//! Implements algorithms for isolating real roots of polynomials including:
4//! - Sturm sequences
5//! - Descartes' rule of signs
6//! - Continued fraction method
7//! - Bisection refinement
8
9#[allow(unused_imports)]
10use crate::prelude::*;
11use num_bigint::BigInt;
12use num_rational::BigRational;
13use num_traits::Zero;
14
15/// Safety-net bisection-depth ceiling for [`RootIsolator::isolate_roots`]'s
16/// bisection search (`bisect_and_isolate`).
17///
18/// Independent of [`RootIsolator`]'s `max_iterations` field, which bounds a
19/// different loop: the *refinement* of an already-isolated single-root
20/// interval in [`RootIsolator::refine_root_interval`], not the isolation
21/// search itself. See [`IsolationStats::incomplete`] for why this is a pure
22/// safety net rather than a correctness-affecting parameter, and
23/// `crate::algebraic::isolate`'s sibling implementation of the same
24/// algorithm (or `oxiz-nlsat`'s `SturmSequence::isolate_in_interval_bounded`,
25/// same algorithm again over a different `Polynomial` type) for the same
26/// defensive pattern applied elsewhere in this workspace.
27const MAX_ROOT_ISOLATION_DEPTH: u32 = 4096;
28
29/// Root isolation engine for real polynomials.
30pub struct RootIsolator {
31    /// Precision threshold for root refinement
32    precision: BigRational,
33    /// Maximum refinement iterations
34    max_iterations: usize,
35    /// Statistics
36    stats: IsolationStats,
37}
38
39/// Isolated root interval.
40#[derive(Debug, Clone)]
41pub struct RootInterval {
42    /// Left endpoint
43    pub left: BigRational,
44    /// Right endpoint
45    pub right: BigRational,
46    /// Is left endpoint included
47    pub left_closed: bool,
48    /// Is right endpoint included
49    pub right_closed: bool,
50    /// Number of roots in this interval
51    pub multiplicity: usize,
52}
53
54/// Root isolation statistics.
55#[derive(Debug, Clone, Default)]
56pub struct IsolationStats {
57    /// Number of Sturm sequence evaluations
58    pub sturm_evaluations: usize,
59    /// Number of Descartes tests
60    pub descartes_tests: usize,
61    /// Number of bisection steps
62    pub bisection_steps: usize,
63    /// Total intervals generated
64    pub intervals_generated: usize,
65    /// Set to `true` if the bisection search ever hit
66    /// `MAX_ROOT_ISOLATION_DEPTH` before narrowing a sub-interval down to
67    /// exactly one root.
68    ///
69    /// With a correct Sturm sequence and exact rational bisection this
70    /// should never happen for any well-formed polynomial: distinct real
71    /// roots always have a positive minimum pairwise separation, so repeated
72    /// bisection is mathematically guaranteed to isolate each one
73    /// eventually. A `true` value indicates a pathological input or an
74    /// upstream bug, and it also means [`RootIsolator::isolate_roots`]'s
75    /// returned list may be missing one or more roots for the affected
76    /// sub-interval -- this flag makes that condition visible instead of it
77    /// being a silently-incomplete result.
78    pub incomplete: bool,
79}
80
81/// One pending sub-interval in [`RootIsolator::bisect_and_isolate`]'s
82/// explicit work-stack, carrying the sign-variation counts already known
83/// for its endpoints and its remaining bisection-depth budget.
84struct BisectItem {
85    /// Lower endpoint.
86    lo: BigRational,
87    /// Upper endpoint.
88    hi: BigRational,
89    /// Sign-variation count at `lo`.
90    lo_vars: usize,
91    /// Sign-variation count at `hi`.
92    hi_vars: usize,
93    /// Remaining bisection-depth budget (see [`MAX_ROOT_ISOLATION_DEPTH`]).
94    depth: u32,
95}
96
97impl RootIsolator {
98    /// Create a new root isolator.
99    pub fn new(precision: BigRational) -> Self {
100        Self {
101            precision,
102            max_iterations: 1000,
103            stats: IsolationStats::default(),
104        }
105    }
106
107    /// Isolate all real roots of a polynomial in an interval.
108    pub fn isolate_roots(
109        &mut self,
110        poly: &[BigRational],
111        interval: (BigRational, BigRational),
112    ) -> Vec<RootInterval> {
113        self.isolate_roots_bounded(poly, interval, MAX_ROOT_ISOLATION_DEPTH)
114    }
115
116    /// [`Self::isolate_roots`] with an explicit bisection-depth ceiling, so
117    /// tests can force the [`IsolationStats::incomplete`] path
118    /// deterministically without needing a pathological polynomial that
119    /// requires thousands of bisection levels.
120    fn isolate_roots_bounded(
121        &mut self,
122        poly: &[BigRational],
123        interval: (BigRational, BigRational),
124        max_depth: u32,
125    ) -> Vec<RootInterval> {
126        // Remove leading zeros
127        let poly = Self::normalize_polynomial(poly);
128
129        if poly.len() <= 1 {
130            return vec![];
131        }
132
133        // Build Sturm sequence
134        let sturm_seq = self.build_sturm_sequence(&poly);
135
136        // Count sign variations at endpoints. Normalize a reversed/degenerate
137        // interval (`left > right`) up front rather than let a Sturm
138        // sign-variation count for the (higher-x) `left` come out smaller
139        // than for `right` — sign variations are non-increasing as x
140        // increases, so a reversed pair would otherwise underflow the
141        // `usize` subtraction below.
142        let (left, right) = interval;
143        let (left, right) = if left <= right {
144            (left, right)
145        } else {
146            (right, left)
147        };
148        let left_variations = self.count_sign_variations(&sturm_seq, &left);
149        let right_variations = self.count_sign_variations(&sturm_seq, &right);
150        self.stats.sturm_evaluations += 2;
151
152        // Defense in depth: even for a well-ordered interval, use a
153        // saturating subtraction so a degenerate/edge-case count (e.g. an
154        // interval collapsing to a point) can never panic.
155        let num_roots = left_variations.saturating_sub(right_variations);
156
157        if num_roots == 0 {
158            return vec![];
159        } else if num_roots == 1 {
160            // Single root - refine to desired precision
161            let refined = self.refine_root_interval(&poly, left, right);
162            return vec![refined];
163        }
164
165        // Multiple roots - bisect
166        self.bisect_and_isolate(
167            &poly,
168            &sturm_seq,
169            BisectItem {
170                lo: left,
171                hi: right,
172                lo_vars: left_variations,
173                hi_vars: right_variations,
174                depth: max_depth,
175            },
176        )
177    }
178
179    /// Build Sturm sequence for a polynomial.
180    fn build_sturm_sequence(&self, poly: &[BigRational]) -> Vec<Vec<BigRational>> {
181        let mut sequence = Vec::new();
182
183        // f_0 = f(x)
184        sequence.push(poly.to_vec());
185
186        // f_1 = f'(x)
187        let derivative = Self::derivative(poly);
188        if derivative.is_empty() {
189            return sequence;
190        }
191        sequence.push(derivative);
192
193        // f_{i+1} = -remainder(f_{i-1}, f_i)
194        loop {
195            let len = sequence.len();
196            let f_prev = &sequence[len - 2];
197            let f_curr = &sequence[len - 1];
198
199            let remainder = Self::polynomial_remainder(f_prev, f_curr);
200
201            if remainder.is_empty() || Self::is_zero_poly(&remainder) {
202                break;
203            }
204
205            // Negate remainder
206            let neg_remainder: Vec<BigRational> = remainder.iter().map(|c| -c.clone()).collect();
207
208            sequence.push(neg_remainder);
209        }
210
211        sequence
212    }
213
214    /// Count sign variations in a Sturm sequence at a point.
215    fn count_sign_variations(&self, sturm_seq: &[Vec<BigRational>], x: &BigRational) -> usize {
216        let mut signs = Vec::new();
217
218        for poly in sturm_seq {
219            let value = Self::evaluate(poly, x);
220            if !value.is_zero() {
221                signs.push(value > BigRational::zero());
222            }
223        }
224
225        // Count sign changes
226        let mut variations = 0;
227        for i in 0..signs.len().saturating_sub(1) {
228            if signs[i] != signs[i + 1] {
229                variations += 1;
230            }
231        }
232
233        variations
234    }
235
236    /// Bisect an interval and isolate the roots within it.
237    ///
238    /// Iterative (explicit work-stack), not mutually recursive with
239    /// [`Self::isolate_roots`], with `initial.depth` as a defensive
240    /// bisection-depth ceiling (see [`IsolationStats::incomplete`] for why
241    /// this should never actually bind for a well-formed polynomial). Each
242    /// work item carries the sign-variation counts already known for its
243    /// endpoints -- inherited from the bisection that produced it, or from
244    /// the caller for the initial item -- so no endpoint is ever
245    /// re-evaluated. The original mutual recursion re-built the Sturm
246    /// sequence and re-evaluated shared endpoints at every level instead;
247    /// the isolated intervals returned here are identical, this just does
248    /// not repeat work to get them.
249    fn bisect_and_isolate(
250        &mut self,
251        poly: &[BigRational],
252        sturm_seq: &[Vec<BigRational>],
253        initial: BisectItem,
254    ) -> Vec<RootInterval> {
255        let mut intervals = Vec::new();
256        let mut work = vec![initial];
257
258        while let Some(BisectItem {
259            lo,
260            hi,
261            lo_vars,
262            hi_vars,
263            depth,
264        }) = work.pop()
265        {
266            let num_roots = lo_vars.saturating_sub(hi_vars);
267
268            if num_roots == 0 {
269                continue;
270            }
271            if num_roots == 1 {
272                intervals.push(self.refine_root_interval(poly, lo, hi));
273                continue;
274            }
275            if depth == 0 {
276                // Defensive-only fallback (see `IsolationStats::incomplete`):
277                // dropping this sub-range rather than recursing forever or
278                // fabricating a single interval that falsely claims to
279                // isolate multiple roots. Recorded so callers can detect it.
280                self.stats.incomplete = true;
281                continue;
282            }
283
284            self.stats.bisection_steps += 1;
285            let mid = (&lo + &hi) / BigRational::from_integer(BigInt::from(2));
286            let mid_vars = self.count_sign_variations(sturm_seq, &mid);
287            self.stats.sturm_evaluations += 1;
288
289            let left_roots = lo_vars.saturating_sub(mid_vars);
290            let right_roots = mid_vars.saturating_sub(hi_vars);
291
292            // Push right first so left (pushed last) pops first and its
293            // whole subtree -- including everything it in turn pushes -- is
294            // drained before right is touched, matching the original
295            // recursion's left-to-right result order.
296            if right_roots > 0 {
297                work.push(BisectItem {
298                    lo: mid.clone(),
299                    hi,
300                    lo_vars: mid_vars,
301                    hi_vars,
302                    depth: depth - 1,
303                });
304            }
305            if left_roots > 0 {
306                work.push(BisectItem {
307                    lo,
308                    hi: mid,
309                    lo_vars,
310                    hi_vars: mid_vars,
311                    depth: depth - 1,
312                });
313            }
314        }
315
316        intervals
317    }
318
319    /// Refine a root interval to desired precision.
320    fn refine_root_interval(
321        &mut self,
322        poly: &[BigRational],
323        mut left: BigRational,
324        mut right: BigRational,
325    ) -> RootInterval {
326        let mut iterations = 0;
327
328        while &right - &left > self.precision && iterations < self.max_iterations {
329            let mid = (&left + &right) / BigRational::from_integer(BigInt::from(2));
330            let mid_val = Self::evaluate(poly, &mid);
331
332            if mid_val.is_zero() {
333                return RootInterval {
334                    left: mid.clone(),
335                    right: mid,
336                    left_closed: true,
337                    right_closed: true,
338                    multiplicity: 1,
339                };
340            }
341
342            let left_val = Self::evaluate(poly, &left);
343
344            if (left_val > BigRational::zero()) == (mid_val > BigRational::zero()) {
345                left = mid;
346            } else {
347                right = mid;
348            }
349
350            iterations += 1;
351        }
352
353        self.stats.intervals_generated += 1;
354
355        RootInterval {
356            left,
357            right,
358            left_closed: false,
359            right_closed: false,
360            multiplicity: 1,
361        }
362    }
363
364    /// Evaluate polynomial at a point using Horner's method.
365    fn evaluate(poly: &[BigRational], x: &BigRational) -> BigRational {
366        if poly.is_empty() {
367            return BigRational::zero();
368        }
369
370        let mut result = poly[0].clone();
371        for coeff in &poly[1..] {
372            result = result * x + coeff;
373        }
374        result
375    }
376
377    /// Compute polynomial derivative.
378    fn derivative(poly: &[BigRational]) -> Vec<BigRational> {
379        if poly.len() <= 1 {
380            return vec![];
381        }
382
383        let mut deriv = Vec::with_capacity(poly.len() - 1);
384        for (i, coeff) in poly.iter().enumerate().take(poly.len() - 1) {
385            let degree = (poly.len() - 1 - i) as i64;
386            deriv.push(coeff * BigRational::from_integer(BigInt::from(degree)));
387        }
388        deriv
389    }
390
391    /// Polynomial division - compute remainder.
392    fn polynomial_remainder(dividend: &[BigRational], divisor: &[BigRational]) -> Vec<BigRational> {
393        if divisor.is_empty() || Self::is_zero_poly(divisor) {
394            return vec![];
395        }
396
397        let mut remainder = dividend.to_vec();
398
399        while remainder.len() >= divisor.len() && !Self::is_zero_poly(&remainder) {
400            let lead_div = &divisor[0];
401            let lead_rem = &remainder[0];
402
403            if lead_div.is_zero() {
404                break;
405            }
406
407            let quotient_coeff = lead_rem / lead_div;
408
409            for i in 0..divisor.len() {
410                remainder[i] = &remainder[i] - &quotient_coeff * &divisor[i];
411            }
412
413            remainder.remove(0);
414        }
415
416        remainder
417    }
418
419    /// Normalize polynomial by removing leading zeros.
420    fn normalize_polynomial(poly: &[BigRational]) -> Vec<BigRational> {
421        let mut result = poly.to_vec();
422        while !result.is_empty() && result[0].is_zero() {
423            result.remove(0);
424        }
425        result
426    }
427
428    /// Check if polynomial is zero.
429    fn is_zero_poly(poly: &[BigRational]) -> bool {
430        poly.iter().all(|c| c.is_zero())
431    }
432
433    /// Get statistics.
434    pub fn stats(&self) -> &IsolationStats {
435        &self.stats
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use num_traits::{One, Zero};
443
444    #[test]
445    fn test_root_isolator() {
446        let precision = BigRational::new(BigInt::from(1), BigInt::from(1000));
447        let isolator = RootIsolator::new(precision);
448
449        assert_eq!(isolator.stats.sturm_evaluations, 0);
450    }
451
452    #[test]
453    fn test_sturm_sequence() {
454        let precision = BigRational::new(BigInt::from(1), BigInt::from(100));
455        let isolator = RootIsolator::new(precision);
456
457        // f(x) = x^2 - 2
458        let poly = vec![
459            BigRational::one(),
460            BigRational::zero(),
461            BigRational::from_integer(BigInt::from(-2)),
462        ];
463
464        let sturm = isolator.build_sturm_sequence(&poly);
465        assert!(!sturm.is_empty());
466    }
467
468    fn rat(n: i64) -> BigRational {
469        BigRational::from_integer(BigInt::from(n))
470    }
471
472    // -----------------------------------------------------------------------
473    // `bisect_and_isolate` bisection-depth regression tests (audit: no
474    // bound at all on the bisection recursion; `oxiz-nlsat`'s sibling
475    // implementation of the same algorithm already carries a 4096-level
476    // safety net).
477    // -----------------------------------------------------------------------
478
479    #[test]
480    fn test_isolate_roots_multiple_roots_via_bisection_behaviour_preserved() {
481        // f(x) = x^2 - 2 (descending coeffs), two real roots (+-sqrt(2)):
482        // isolate_roots must bisect a wide bracketing interval to separate
483        // them. This exact path (num_roots > 1) had no prior test coverage
484        // in this file.
485        let poly = vec![rat(1), rat(0), rat(-2)];
486        let precision = BigRational::new(BigInt::from(1), BigInt::from(1_000_000));
487        let mut isolator = RootIsolator::new(precision);
488
489        let intervals = isolator.isolate_roots(&poly, (rat(-10), rat(10)));
490
491        assert_eq!(intervals.len(), 2, "x^2 - 2 has exactly two real roots");
492        for iv in &intervals {
493            assert!(iv.left <= iv.right);
494        }
495        assert!(
496            intervals[0].right <= intervals[1].left || intervals[1].right <= intervals[0].left,
497            "isolating intervals must not overlap: {:?}",
498            intervals
499        );
500        assert!(
501            !isolator.stats().incomplete,
502            "a normal, well-separated polynomial must never hit the depth cap"
503        );
504    }
505
506    #[test]
507    fn test_isolate_roots_bounded_depth_cap_is_visible_not_silent() {
508        // f(x) = x^3 - x = x(x-1)(x+1), three real roots. With a bisection
509        // budget too small to separate all three, the search must stop and
510        // record `stats.incomplete = true` -- never hang, never fabricate a
511        // merged interval that falsely claims to isolate multiple roots.
512        let poly = vec![rat(1), rat(0), rat(-1), rat(0)];
513        let precision = BigRational::new(BigInt::from(1), BigInt::from(1_000_000));
514        let mut isolator = RootIsolator::new(precision);
515
516        let results = isolator.isolate_roots_bounded(&poly, (rat(-10), rat(10)), 1);
517
518        assert!(
519            isolator.stats().incomplete,
520            "an insufficient depth budget must be recorded as incomplete"
521        );
522        assert!(
523            results.len() < 3,
524            "a truncated search must not fabricate all three roots, got {results:?}"
525        );
526    }
527
528    #[test]
529    fn test_isolate_roots_bounded_sufficient_depth_never_marks_incomplete() {
530        // The same cubic through the public API (default
531        // MAX_ROOT_ISOLATION_DEPTH) must isolate all three roots and never
532        // set `incomplete`.
533        let poly = vec![rat(1), rat(0), rat(-1), rat(0)];
534        let precision = BigRational::new(BigInt::from(1), BigInt::from(1_000_000));
535        let mut isolator = RootIsolator::new(precision);
536
537        let intervals = isolator.isolate_roots(&poly, (rat(-10), rat(10)));
538
539        assert_eq!(intervals.len(), 3);
540        assert!(!isolator.stats().incomplete);
541    }
542
543    #[test]
544    fn test_isolate_roots_deep_bisection_small_stack() {
545        // Two real roots 2^-2000 apart force many bisection levels (well
546        // under MAX_ROOT_ISOLATION_DEPTH, so this is a *deep-but-finite*
547        // case, not the depth-cap path) from inside a thread with a
548        // deliberately small (1 MiB) stack. A stack overflow aborts the
549        // whole process, so "the thread returned at all" is itself part of
550        // the assertion.
551        let handle = std::thread::Builder::new()
552            .stack_size(1 << 20)
553            .spawn(|| {
554                let mut den = BigInt::from(1u32);
555                for _ in 0..2000 {
556                    den *= 2;
557                }
558                let eps = BigRational::new(BigInt::from(1), den);
559                // f(x) = x*(x - eps) = x^2 - eps*x (descending coeffs)
560                let poly = vec![rat(1), -eps, rat(0)];
561                // Precision only bounds the post-isolation refinement step
562                // (via `max_iterations`, itself already bounded), not the
563                // isolation/bisection search depth being tested here, so a
564                // modest value is enough.
565                let precision = BigRational::new(BigInt::from(1), BigInt::from(1_000_000));
566                let mut isolator = RootIsolator::new(precision);
567                let intervals = isolator.isolate_roots(&poly, (rat(-1), rat(1)));
568                assert_eq!(intervals.len(), 2);
569                assert!(!isolator.stats().incomplete);
570            })
571            .expect("spawning a thread with an explicit stack size must succeed");
572        handle
573            .join()
574            .expect("a deep-but-finite bisection must not overflow a 1 MiB stack");
575    }
576}