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] - "ient_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}