Skip to main content

ogeom_math/
solve.rs

1//! Root finding, polynomial roots and minimization.
2//!
3//! The numerical substrate every intersection, projection and extrema algorithm
4//! in the kernel sits on. Nothing here is geometric; it is deliberately kept
5//! separate so those algorithms are about geometry rather than about
6//! convergence.
7//!
8//! # What to reach for
9//!
10//! - A root inside a known bracket: [`brent`]. Guaranteed to converge, and
11//!   nearly as fast as Newton in practice.
12//! - A root with a known derivative and a good starting point: [`newton`],
13//!   which falls back to bisection whenever a step would leave the bracket.
14//!   Unsafeguarded Newton diverges on the configurations that matter: a
15//!   tangential intersection is exactly where the derivative vanishes.
16//! - Roots of a polynomial up to quartic: [`roots`]. Closed form, and the
17//!   quadratic is written to avoid the cancellation the schoolbook formula
18//!   suffers.
19//! - A system of equations: [`newton_system`]. Surface projection is two
20//!   equations in two unknowns; intersection marching is much the same.
21//! - A minimum without derivatives: [`minimize`].
22
23use nalgebra::{DMatrix, DVector};
24use ogeom_core::{OgeomResult, ogeom_bail};
25
26/// How a solver finished.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Convergence {
29    /// The residual fell below the requested tolerance.
30    Residual,
31    /// The step size fell below the requested tolerance.
32    Step,
33    /// The iteration limit was reached first. The result is the best estimate
34    /// found, and is *not* to be treated as a root.
35    Exhausted,
36}
37
38impl Convergence {
39    /// Whether the solver actually converged.
40    #[must_use]
41    pub const fn is_converged(self) -> bool {
42        !matches!(self, Self::Exhausted)
43    }
44}
45
46/// A solver result: the estimate, the residual there, and how it finished.
47#[derive(Debug, Clone, Copy, PartialEq)]
48pub struct Solution {
49    /// The parameter value.
50    pub value: f64,
51    /// The function's value there.
52    pub residual: f64,
53    /// How the iteration ended.
54    pub convergence: Convergence,
55    /// Iterations taken.
56    pub iterations: usize,
57}
58
59/// Stopping criteria for an iterative solver.
60#[derive(Debug, Clone, Copy, PartialEq)]
61pub struct Criteria {
62    /// Stop when `|f(x)|` falls to this.
63    pub residual: f64,
64    /// Stop when the step falls to this.
65    pub step: f64,
66    /// Give up after this many iterations.
67    pub max_iterations: usize,
68}
69
70impl Default for Criteria {
71    fn default() -> Self {
72        Self {
73            // Tight enough for geometric work in f64 without chasing the last
74            // couple of bits, which costs iterations and buys nothing.
75            residual: 1e-13,
76            step: 1e-14,
77            max_iterations: 100,
78        }
79    }
80}
81
82impl Criteria {
83    /// Criteria with a given residual tolerance and the default step limit.
84    #[must_use]
85    pub fn with_residual(residual: f64) -> Self {
86        Self {
87            residual,
88            ..Self::default()
89        }
90    }
91}
92
93/// Find a root of `f` in `[a, b]` by Brent's method.
94///
95/// Combines bisection, the secant method and inverse quadratic interpolation,
96/// taking whichever step is both safe and fast. Guaranteed to converge for a
97/// continuous function that changes sign across the bracket, and superlinear in
98/// practice, the right default when a bracket is available.
99///
100/// # Errors
101///
102/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the bracket is
103/// malformed, or if `f` does not change sign across it, which means there is no
104/// root to find by this method.
105pub fn brent<F>(mut f: F, a: f64, b: f64, criteria: Criteria) -> OgeomResult<Solution>
106where
107    F: FnMut(f64) -> f64,
108{
109    if !a.is_finite() || !b.is_finite() || a >= b {
110        ogeom_bail!(Construction, "bracket [{a}, {b}] is empty or non-finite");
111    }
112    let (mut fa, mut fb) = (f(a), f(b));
113    if fa == 0.0 {
114        return Ok(Solution {
115            value: a,
116            residual: 0.0,
117            convergence: Convergence::Residual,
118            iterations: 0,
119        });
120    }
121    if fb == 0.0 {
122        return Ok(Solution {
123            value: b,
124            residual: 0.0,
125            convergence: Convergence::Residual,
126            iterations: 0,
127        });
128    }
129    if fa * fb > 0.0 {
130        ogeom_bail!(
131            Construction,
132            "f does not change sign across [{a}, {b}]: f(a) = {fa}, f(b) = {fb}"
133        );
134    }
135
136    let (mut a, mut b) = (a, b);
137    // `b` is kept as the better estimate throughout.
138    if fa.abs() < fb.abs() {
139        core::mem::swap(&mut a, &mut b);
140        core::mem::swap(&mut fa, &mut fb);
141    }
142    let mut c = a;
143    let mut fc = fa;
144    let mut previous_step = b - a;
145    let mut used_bisection = true;
146
147    for iteration in 1..=criteria.max_iterations {
148        let mut s = if fa != fc && fb != fc {
149            // Inverse quadratic interpolation, when three distinct values allow
150            // it.
151            a * fb * fc / ((fa - fb) * (fa - fc))
152                + b * fa * fc / ((fb - fa) * (fb - fc))
153                + c * fa * fb / ((fc - fa) * (fc - fb))
154        } else {
155            b - fb * (b - a) / (fb - fa)
156        };
157
158        // Bisect instead whenever the interpolated step is outside the bracket
159        // or is not shrinking fast enough. This is what turns a fast but
160        // unreliable method into a guaranteed one.
161        let bounds = ((3.0 * a + b) / 4.0, b);
162        let outside = if bounds.0 < bounds.1 {
163            s < bounds.0 || s > bounds.1
164        } else {
165            s < bounds.1 || s > bounds.0
166        };
167        let step = (s - b).abs();
168        let stalled = if used_bisection {
169            step >= (b - c).abs() / 2.0
170        } else {
171            step >= previous_step.abs() / 2.0
172        };
173        if outside || stalled || previous_step.abs() < criteria.step {
174            s = f64::midpoint(a, b);
175            used_bisection = true;
176        } else {
177            used_bisection = false;
178        }
179
180        let fs = f(s);
181        previous_step = b - c;
182        c = b;
183        fc = fb;
184        if fa * fs < 0.0 {
185            b = s;
186            fb = fs;
187        } else {
188            a = s;
189            fa = fs;
190        }
191        if fa.abs() < fb.abs() {
192            core::mem::swap(&mut a, &mut b);
193            core::mem::swap(&mut fa, &mut fb);
194        }
195
196        if fb.abs() <= criteria.residual {
197            return Ok(Solution {
198                value: b,
199                residual: fb,
200                convergence: Convergence::Residual,
201                iterations: iteration,
202            });
203        }
204        if (b - a).abs() <= criteria.step {
205            return Ok(Solution {
206                value: b,
207                residual: fb,
208                convergence: Convergence::Step,
209                iterations: iteration,
210            });
211        }
212    }
213    Ok(Solution {
214        value: b,
215        residual: fb,
216        convergence: Convergence::Exhausted,
217        iterations: criteria.max_iterations,
218    })
219}
220
221/// Find a root of `f` near `start`, using its derivative, safeguarded by a
222/// bracket.
223///
224/// Takes a Newton step when that lands inside `[a, b]` and reduces the residual,
225/// and bisects otherwise. Plain Newton is not usable here: it diverges wherever
226/// the derivative is small, and small derivatives are precisely the tangential
227/// configurations a geometry kernel spends its time on.
228///
229/// `f` returns the value and the derivative together, since evaluating them
230/// separately usually repeats most of the work.
231///
232/// # Errors
233///
234/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the bracket is
235/// malformed or `f` does not change sign across it.
236pub fn newton<F>(mut f: F, a: f64, b: f64, start: f64, criteria: Criteria) -> OgeomResult<Solution>
237where
238    F: FnMut(f64) -> (f64, f64),
239{
240    if !a.is_finite() || !b.is_finite() || a >= b {
241        ogeom_bail!(Construction, "bracket [{a}, {b}] is empty or non-finite");
242    }
243    let (mut low, mut high) = (a, b);
244    let (fa, _) = f(low);
245    let (fb, _) = f(high);
246    if fa == 0.0 {
247        return Ok(Solution {
248            value: low,
249            residual: 0.0,
250            convergence: Convergence::Residual,
251            iterations: 0,
252        });
253    }
254    if fb == 0.0 {
255        return Ok(Solution {
256            value: high,
257            residual: 0.0,
258            convergence: Convergence::Residual,
259            iterations: 0,
260        });
261    }
262    if fa * fb > 0.0 {
263        ogeom_bail!(Construction, "f does not change sign across [{a}, {b}]");
264    }
265    // Orient so that f(low) < 0 < f(high); the bracket update is then a single
266    // comparison rather than a sign product.
267    if fa > 0.0 {
268        core::mem::swap(&mut low, &mut high);
269    }
270
271    let mut x = start.clamp(a, b);
272    let mut previous_step = (b - a).abs();
273
274    for iteration in 1..=criteria.max_iterations {
275        let (value, slope) = f(x);
276        if value.abs() <= criteria.residual {
277            return Ok(Solution {
278                value: x,
279                residual: value,
280                convergence: Convergence::Residual,
281                iterations: iteration,
282            });
283        }
284        if value < 0.0 {
285            low = x;
286        } else {
287            high = x;
288        }
289
290        let newton_step = if slope == 0.0 {
291            f64::INFINITY
292        } else {
293            value / slope
294        };
295        let candidate = x - newton_step;
296        let out_of_bracket = (candidate - low) * (candidate - high) > 0.0;
297        // A step that has not at least halved is a sign Newton is not making
298        // progress here, so fall back rather than grind.
299        let too_slow = (2.0 * newton_step).abs() > previous_step;
300
301        let next = if out_of_bracket || too_slow || !candidate.is_finite() {
302            f64::midpoint(low, high)
303        } else {
304            candidate
305        };
306        previous_step = (next - x).abs();
307        x = next;
308
309        if previous_step <= criteria.step {
310            let (residual, _) = f(x);
311            return Ok(Solution {
312                value: x,
313                residual,
314                convergence: Convergence::Step,
315                iterations: iteration,
316            });
317        }
318    }
319    let (residual, _) = f(x);
320    Ok(Solution {
321        value: x,
322        residual,
323        convergence: Convergence::Exhausted,
324        iterations: criteria.max_iterations,
325    })
326}
327
328/// The real roots of a polynomial, in increasing order.
329///
330/// `coefficients` are in ascending power order: `c[0] + c[1] x + c[2] x^2 ...`.
331/// Degrees up to four are solved in closed form; above that the polynomial is
332/// deflated by companion-matrix eigenvalues.
333///
334/// Repeated roots are returned once each, since a geometry caller wants the
335/// distinct parameter values.
336///
337/// # Errors
338///
339/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if every
340/// coefficient is zero, where every value is a root.
341pub fn roots(coefficients: &[f64], tolerance: f64) -> OgeomResult<Vec<f64>> {
342    // Drop leading zeros so the true degree drives the choice of method; a
343    // "cubic" whose cubic term is zero is a quadratic and must be solved as
344    // one, or the leading division blows up.
345    let mut c = coefficients;
346    while let Some((&last, rest)) = c.split_last() {
347        if last.abs() <= tolerance * c.iter().fold(0.0_f64, |m, v| m.max(v.abs())).max(1.0) {
348            c = rest;
349        } else {
350            break;
351        }
352    }
353
354    let mut out = match c.len() {
355        0 => ogeom_bail!(
356            Construction,
357            "the zero polynomial has every value as a root"
358        ),
359        1 => Vec::new(),
360        2 => vec![-c[0] / c[1]],
361        3 => quadratic_roots(c[2], c[1], c[0]),
362        4 => cubic_roots(c[3], c[2], c[1], c[0]),
363        _ => companion_roots(c, tolerance),
364    };
365
366    out.retain(|r| r.is_finite());
367    out.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
368    out.dedup_by(|a, b| (*a - *b).abs() <= tolerance * a.abs().max(1.0));
369    Ok(out)
370}
371
372/// Real roots of `a x^2 + b x + c`.
373///
374/// Uses the citardauq form for whichever root would otherwise be computed as a
375/// difference of nearly equal numbers. The schoolbook formula loses most of its
376/// precision for the smaller root when `b^2 >> 4ac`, which is the common case
377/// for a ray grazing a sphere.
378#[must_use]
379pub fn quadratic_roots(a: f64, b: f64, c: f64) -> Vec<f64> {
380    if a == 0.0 {
381        return if b == 0.0 { Vec::new() } else { vec![-c / b] };
382    }
383    let discriminant = b.mul_add(b, -(4.0 * a * c));
384    if discriminant < 0.0 {
385        return Vec::new();
386    }
387    if discriminant == 0.0 {
388        return vec![-b / (2.0 * a)];
389    }
390    let sqrt = discriminant.sqrt();
391    // Add magnitudes rather than subtract them, then get the other root from
392    // the product relation.
393    let q = -0.5 * (b + b.signum() * sqrt);
394    let (r1, r2) = (q / a, if q == 0.0 { 0.0 } else { c / q });
395    if r1 <= r2 { vec![r1, r2] } else { vec![r2, r1] }
396}
397
398/// Real roots of `a x^3 + b x^2 + c x + d`.
399///
400/// Depressed cubic plus the trigonometric solution in the three-real-roots
401/// case, which avoids the complex arithmetic Cardano's formula would otherwise
402/// need there.
403#[must_use]
404pub fn cubic_roots(a: f64, b: f64, c: f64, d: f64) -> Vec<f64> {
405    if a == 0.0 {
406        return quadratic_roots(b, c, d);
407    }
408    let (b, c, d) = (b / a, c / a, d / a);
409    let shift = b / 3.0;
410    // x = t - b/3 removes the quadratic term.
411    let p = shift.mul_add(-b, c);
412    let q = (2.0 / 27.0 * b * b).mul_add(b, shift.mul_add(-c, d));
413
414    let half_q = q / 2.0;
415    let third_p = p / 3.0;
416    let discriminant = half_q.mul_add(half_q, third_p * third_p * third_p);
417
418    if discriminant > 0.0 {
419        let sqrt = discriminant.sqrt();
420        let u = (-half_q + sqrt).cbrt();
421        let v = (-half_q - sqrt).cbrt();
422        vec![u + v - shift]
423    } else if discriminant == 0.0 {
424        if p == 0.0 {
425            vec![-shift]
426        } else {
427            let u = (-half_q).cbrt();
428            let mut r = vec![2.0 * u - shift, -u - shift];
429            r.sort_by(|x, y| x.partial_cmp(y).unwrap_or(core::cmp::Ordering::Equal));
430            r
431        }
432    } else {
433        // Three distinct real roots, via trigonometry.
434        let radius = (-third_p).sqrt();
435        let cos = (-half_q / (radius * radius * radius)).clamp(-1.0, 1.0);
436        let angle = cos.acos() / 3.0;
437        let scale = 2.0 * radius;
438        let tau_third = core::f64::consts::TAU / 3.0;
439        let mut r = vec![
440            scale.mul_add(angle.cos(), -shift),
441            scale.mul_add((angle - tau_third).cos(), -shift),
442            scale.mul_add((angle + tau_third).cos(), -shift),
443        ];
444        r.sort_by(|x, y| x.partial_cmp(y).unwrap_or(core::cmp::Ordering::Equal));
445        r
446    }
447}
448
449/// Real roots of a polynomial of any degree, via companion-matrix eigenvalues.
450fn companion_roots(c: &[f64], tolerance: f64) -> Vec<f64> {
451    let n = c.len() - 1;
452    let lead = c[n];
453    let mut m = DMatrix::<f64>::zeros(n, n);
454    for i in 0..n {
455        m[(i, n - 1)] = -c[i] / lead;
456        if i + 1 < n {
457            m[(i + 1, i)] = 1.0;
458        }
459    }
460    // Only the real eigenvalues are roots; complex conjugate pairs are not.
461    m.complex_eigenvalues()
462        .iter()
463        .filter(|e| e.im.abs() <= tolerance.max(1e-9) * e.re.abs().max(1.0))
464        .map(|e| e.re)
465        .collect()
466}
467
468/// Minimize a scalar function on `[a, b]` without derivatives.
469///
470/// Brent's method again: golden-section search with parabolic interpolation
471/// wherever the parabola is well behaved. Converges for any continuous function
472/// and is not fooled by the flat regions near a minimum, where a derivative
473/// method has nothing to work with.
474///
475/// # Errors
476///
477/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the bracket is
478/// malformed.
479pub fn minimize<F>(mut f: F, a: f64, b: f64, criteria: Criteria) -> OgeomResult<Solution>
480where
481    F: FnMut(f64) -> f64,
482{
483    if !a.is_finite() || !b.is_finite() || a >= b {
484        ogeom_bail!(Construction, "bracket [{a}, {b}] is empty or non-finite");
485    }
486    // 1 - 1/phi: the golden-section step.
487    const GOLDEN: f64 = 0.381_966_011_250_105_15;
488
489    let (mut low, mut high) = (a, b);
490    let mut x = GOLDEN.mul_add(b - a, a);
491    let (mut w, mut v) = (x, x);
492    let mut fx = f(x);
493    let (mut fw, mut fv) = (fx, fx);
494    let mut step = 0.0_f64;
495    let mut previous_step = 0.0_f64;
496
497    for iteration in 1..=criteria.max_iterations {
498        let middle = f64::midpoint(low, high);
499        let tolerance = criteria.step.mul_add(x.abs(), criteria.step);
500        if (x - middle).abs() <= 2.0f64.mul_add(tolerance, -((high - low) / 2.0)) {
501            return Ok(Solution {
502                value: x,
503                residual: fx,
504                convergence: Convergence::Step,
505                iterations: iteration,
506            });
507        }
508
509        let mut use_golden = true;
510        if previous_step.abs() > tolerance {
511            // Fit a parabola through the three best points so far.
512            let r = (x - w) * (fx - fv);
513            let q = (x - v) * (fx - fw);
514            let mut p = (x - v) * q - (x - w) * r;
515            let mut q = 2.0 * (q - r);
516            if q > 0.0 {
517                p = -p;
518            }
519            q = q.abs();
520            // Accept the parabolic step only if it stays inside the bracket and
521            // is smaller than half the step before last.
522            if p.abs() < (0.5 * q * previous_step).abs() && p > q * (low - x) && p < q * (high - x)
523            {
524                step = p / q;
525                let candidate = x + step;
526                if candidate - low < 2.0 * tolerance || high - candidate < 2.0 * tolerance {
527                    step = if x < middle { tolerance } else { -tolerance };
528                }
529                use_golden = false;
530            }
531        }
532        if use_golden {
533            previous_step = if x < middle { high - x } else { low - x };
534            step = GOLDEN * previous_step;
535        }
536
537        let next = if step.abs() >= tolerance {
538            x + step
539        } else if step > 0.0 {
540            x + tolerance
541        } else {
542            x - tolerance
543        };
544        let fnext = f(next);
545
546        if fnext <= fx {
547            if next < x {
548                high = x;
549            } else {
550                low = x;
551            }
552            v = w;
553            fv = fw;
554            w = x;
555            fw = fx;
556            x = next;
557            fx = fnext;
558        } else {
559            if next < x {
560                low = next;
561            } else {
562                high = next;
563            }
564            if fnext <= fw || w == x {
565                v = w;
566                fv = fw;
567                w = next;
568                fw = fnext;
569            } else if fnext <= fv || v == x || v == w {
570                v = next;
571                fv = fnext;
572            }
573        }
574        previous_step = step;
575    }
576    Ok(Solution {
577        value: x,
578        residual: fx,
579        convergence: Convergence::Exhausted,
580        iterations: criteria.max_iterations,
581    })
582}
583
584/// The outcome of solving a system of equations.
585#[derive(Debug, Clone, PartialEq)]
586pub struct SystemSolution {
587    /// The estimate.
588    pub value: Vec<f64>,
589    /// The norm of the residual vector there.
590    pub residual: f64,
591    /// How the iteration ended.
592    pub convergence: Convergence,
593    /// Iterations taken.
594    pub iterations: usize,
595}
596
597/// Solve `f(x) = 0` for a vector `x`, by damped Newton.
598///
599/// `f` returns the residual vector and the Jacobian, row-major. The step is
600/// halved until it actually reduces the residual; undamped Newton overshoots
601/// badly from a poor start, and a geometry caller's start is often only a rough
602/// guess from a coarse sampling.
603///
604/// Surface projection is this with two equations in two unknowns; so is a step
605/// of a surface/surface intersection march.
606///
607/// Where no root exists the residual has a positive minimum, and no damping
608/// finds a downhill step from it. That is reported as
609/// [`Convergence::Exhausted`] with the best estimate attached; "no root here"
610/// is a useful answer, and far better than iterating to the limit.
611///
612/// # Errors
613///
614/// [`OgeomError::Dimension`](ogeom_core::OgeomError::Dimension) if the Jacobian's shape
615/// disagrees with the residual, and
616/// [`OgeomError::Numeric`](ogeom_core::OgeomError::Numeric) if the Jacobian is singular
617/// at the starting point.
618pub fn newton_system<F>(mut f: F, start: &[f64], criteria: Criteria) -> OgeomResult<SystemSolution>
619where
620    F: FnMut(&[f64]) -> (Vec<f64>, Vec<Vec<f64>>),
621{
622    let n = start.len();
623    let mut x = DVector::from_row_slice(start);
624
625    let evaluate = |x: &DVector<f64>, f: &mut F| {
626        let (r, j) = f(x.as_slice());
627        (r, j)
628    };
629
630    let (mut residual, mut jacobian) = evaluate(&x, &mut f);
631    if residual.len() != n || jacobian.len() != n || jacobian.iter().any(|row| row.len() != n) {
632        ogeom_bail!(
633            Dimension,
634            "expected a {n}-vector residual and {n}x{n} Jacobian"
635        );
636    }
637    let mut norm = residual.iter().map(|v| v * v).sum::<f64>().sqrt();
638
639    for iteration in 1..=criteria.max_iterations {
640        if norm <= criteria.residual {
641            return Ok(SystemSolution {
642                value: x.as_slice().to_vec(),
643                residual: norm,
644                convergence: Convergence::Residual,
645                iterations: iteration - 1,
646            });
647        }
648
649        let j = DMatrix::from_fn(n, n, |r, c| jacobian[r][c]);
650        let rhs = DVector::from_row_slice(&residual);
651        let Some(delta) = j.lu().solve(&rhs) else {
652            ogeom_bail!(Numeric, "Jacobian is singular after {iteration} iterations");
653        };
654
655        // Damping: keep halving until the residual actually falls. Without it,
656        // Newton happily steps past the solution and never comes back.
657        let mut scale = 1.0;
658        let mut accepted = None;
659        for _ in 0..30 {
660            let candidate = &x - &delta * scale;
661            let (r, jj) = evaluate(&candidate, &mut f);
662            let candidate_norm = r.iter().map(|v| v * v).sum::<f64>().sqrt();
663            if candidate_norm < norm || candidate_norm <= criteria.residual {
664                accepted = Some((candidate, r, jj, candidate_norm));
665                break;
666            }
667            scale *= 0.5;
668        }
669
670        let Some((next, r, jj, next_norm)) = accepted else {
671            // No downhill step exists: this is a local minimum of the residual,
672            // not a root, and saying so is more useful than iterating forever.
673            return Ok(SystemSolution {
674                value: x.as_slice().to_vec(),
675                residual: norm,
676                convergence: Convergence::Exhausted,
677                iterations: iteration,
678            });
679        };
680
681        let step = (&next - &x).norm();
682        x = next;
683        residual = r;
684        jacobian = jj;
685        norm = next_norm;
686
687        if norm <= criteria.residual {
688            return Ok(SystemSolution {
689                value: x.as_slice().to_vec(),
690                residual: norm,
691                convergence: Convergence::Residual,
692                iterations: iteration,
693            });
694        }
695        if step <= criteria.step {
696            return Ok(SystemSolution {
697                value: x.as_slice().to_vec(),
698                residual: norm,
699                convergence: Convergence::Step,
700                iterations: iteration,
701            });
702        }
703    }
704    Ok(SystemSolution {
705        value: x.as_slice().to_vec(),
706        residual: norm,
707        convergence: Convergence::Exhausted,
708        iterations: criteria.max_iterations,
709    })
710}
711
712/// A two-unknown [`newton_system`], allocation-free.
713///
714/// The foot-point projection runs this system millions of times per real
715/// model, and the general path pays a heap allocation for every residual,
716/// Jacobian, vector and factorization of every damped step. The algorithm
717/// here is the same (damped Newton, halving until the residual falls, the
718/// same three convergence verdicts), with the two-by-two solve written out:
719/// partial pivoting is one comparison, and singularity is a vanishing
720/// pivot.
721///
722/// # Errors
723///
724/// [`OgeomError::Numeric`](ogeom_core::OgeomError::Numeric) if the Jacobian
725/// is singular.
726pub fn newton_system_2<F>(
727    mut f: F,
728    start: [f64; 2],
729    criteria: Criteria,
730) -> OgeomResult<([f64; 2], f64, Convergence, usize)>
731where
732    F: FnMut([f64; 2]) -> ([f64; 2], [[f64; 2]; 2]),
733{
734    let mut x = start;
735    let (mut residual, mut jacobian) = f(x);
736    let mut norm = residual[0].hypot(residual[1]);
737
738    for iteration in 1..=criteria.max_iterations {
739        if norm <= criteria.residual {
740            return Ok((x, norm, Convergence::Residual, iteration - 1));
741        }
742
743        // Solve J * delta = residual, partial pivoting on the first column.
744        let (row0, row1, rhs0, rhs1) = if jacobian[0][0].abs() >= jacobian[1][0].abs() {
745            (jacobian[0], jacobian[1], residual[0], residual[1])
746        } else {
747            (jacobian[1], jacobian[0], residual[1], residual[0])
748        };
749        if row0[0].abs() <= f64::EPSILON * (row1[0].abs() + row0[1].abs()).max(1.0) {
750            ogeom_bail!(Numeric, "Jacobian is singular after {iteration} iterations");
751        }
752        let factor = row1[0] / row0[0];
753        let denom = factor.mul_add(-row0[1], row1[1]);
754        if denom.abs() <= f64::EPSILON * row0[1].abs().max(1.0) {
755            ogeom_bail!(Numeric, "Jacobian is singular after {iteration} iterations");
756        }
757        let d1 = factor.mul_add(-rhs0, rhs1) / denom;
758        let d0 = d1.mul_add(-row0[1], rhs0) / row0[0];
759        let delta = [d0, d1];
760
761        // Damping: keep halving until the residual actually falls.
762        let mut scale = 1.0;
763        let mut accepted = None;
764        for _ in 0..30 {
765            let candidate = [
766                delta[0].mul_add(-scale, x[0]),
767                delta[1].mul_add(-scale, x[1]),
768            ];
769            let (r, jj) = f(candidate);
770            let candidate_norm = r[0].hypot(r[1]);
771            if candidate_norm < norm || candidate_norm <= criteria.residual {
772                accepted = Some((candidate, r, jj, candidate_norm));
773                break;
774            }
775            scale *= 0.5;
776        }
777        let Some((next, r, jj, next_norm)) = accepted else {
778            return Ok((x, norm, Convergence::Exhausted, iteration));
779        };
780
781        let step = (next[0] - x[0]).hypot(next[1] - x[1]);
782        x = next;
783        residual = r;
784        jacobian = jj;
785        norm = next_norm;
786
787        if norm <= criteria.residual {
788            return Ok((x, norm, Convergence::Residual, iteration));
789        }
790        if step <= criteria.step {
791            return Ok((x, norm, Convergence::Step, iteration));
792        }
793    }
794    Ok((x, norm, Convergence::Exhausted, criteria.max_iterations))
795}
796
797#[cfg(test)]
798#[allow(clippy::unwrap_used)]
799mod tests {
800    use super::*;
801    use approx::assert_relative_eq;
802
803    const C: Criteria = Criteria {
804        residual: 1e-13,
805        step: 1e-14,
806        max_iterations: 100,
807    };
808
809    #[test]
810    fn brent_finds_a_simple_root() {
811        let s = brent(|x| x * x - 2.0, 0.0, 2.0, C).unwrap();
812        assert!(s.convergence.is_converged());
813        assert_relative_eq!(s.value, core::f64::consts::SQRT_2, epsilon = 1e-12);
814    }
815
816    #[test]
817    fn brent_handles_a_root_at_a_bracket_end() {
818        let s = brent(|x| x, -1.0, 0.0, C).unwrap();
819        assert_relative_eq!(s.value, 0.0);
820        assert_eq!(s.iterations, 0);
821    }
822
823    #[test]
824    fn brent_refuses_a_bracket_without_a_sign_change() {
825        assert!(brent(|x| x * x + 1.0, -1.0, 1.0, C).is_err());
826        assert!(brent(|x| x, 1.0, 0.0, C).is_err(), "reversed bracket");
827        assert!(brent(|x| x, 0.0, f64::NAN, C).is_err());
828    }
829
830    #[test]
831    fn brent_converges_on_a_function_that_defeats_the_secant_method() {
832        // Very flat near the root, then steep: pure secant crawls, bisection
833        // alone is slow, and the hybrid must do better than either.
834        let s = brent(|x| x.powi(15) - 0.5, 0.0, 2.0, C).unwrap();
835        assert!(s.convergence.is_converged());
836        assert!(s.residual.abs() < 1e-12);
837        assert!(s.iterations < 60, "took {} iterations", s.iterations);
838    }
839
840    #[test]
841    fn newton_converges_faster_than_bisection_when_it_can() {
842        let s = newton(|x| (x * x - 2.0, 2.0 * x), 0.5, 2.0, 1.0, C).unwrap();
843        assert!(s.convergence.is_converged());
844        assert_relative_eq!(s.value, core::f64::consts::SQRT_2, epsilon = 1e-12);
845        assert!(s.iterations < 12, "took {} iterations", s.iterations);
846    }
847
848    #[test]
849    fn newton_survives_a_vanishing_derivative() {
850        // f(x) = x^3 has f'(0) = 0. Unsafeguarded Newton stalls; the bisection
851        // fallback must carry it through.
852        let s = newton(|x| (x * x * x, 3.0 * x * x), -1.0, 2.0, 1.9, C).unwrap();
853        assert!(s.value.abs() < 1e-4, "landed at {}", s.value);
854    }
855
856    #[test]
857    fn newton_survives_a_terrible_starting_point() {
858        for start in [-0.999_f64, 0.0, 1.999, 1.0] {
859            let s = newton(|x| (x * x - 2.0, 2.0 * x), -1.0, 2.0, start, C).unwrap();
860            assert!(
861                (s.value - core::f64::consts::SQRT_2).abs() < 1e-9,
862                "start {start} gave {}",
863                s.value
864            );
865        }
866    }
867
868    #[test]
869    fn quadratic_roots_stay_accurate_when_the_roots_are_far_apart() {
870        // x^2 - (1e8 + 1e-8) x + 1 has roots 1e8 and 1e-8. The schoolbook
871        // formula computes the small one as a difference of nearly equal
872        // numbers and loses almost all of it.
873        let r = quadratic_roots(1.0, -(1e8 + 1e-8), 1.0);
874        assert_eq!(r.len(), 2);
875        assert_relative_eq!(r[0], 1e-8, max_relative = 1e-10);
876        assert_relative_eq!(r[1], 1e8, max_relative = 1e-14);
877    }
878
879    #[test]
880    fn quadratic_edge_cases() {
881        assert_eq!(
882            quadratic_roots(1.0, 0.0, 1.0),
883            Vec::<f64>::new(),
884            "no real roots"
885        );
886        assert_eq!(quadratic_roots(1.0, -2.0, 1.0), vec![1.0], "double root");
887        assert_eq!(
888            quadratic_roots(0.0, 2.0, -4.0),
889            vec![2.0],
890            "degenerates to linear"
891        );
892        assert_eq!(quadratic_roots(0.0, 0.0, 1.0), Vec::<f64>::new());
893        let r = quadratic_roots(1.0, 0.0, -4.0);
894        assert_relative_eq!(r[0], -2.0);
895        assert_relative_eq!(r[1], 2.0);
896    }
897
898    #[test]
899    fn cubic_with_three_real_roots() {
900        // (x + 3)(x - 1)(x - 2) = x^3 - 7x + 6
901        let r = cubic_roots(1.0, 0.0, -7.0, 6.0);
902        assert_eq!(r.len(), 3);
903        assert_relative_eq!(r[0], -3.0, epsilon = 1e-12);
904        assert_relative_eq!(r[1], 1.0, epsilon = 1e-12);
905        assert_relative_eq!(r[2], 2.0, epsilon = 1e-12);
906    }
907
908    #[test]
909    fn cubic_with_one_real_root() {
910        // x^3 + x + 1 has a single real root near -0.6823
911        let r = cubic_roots(1.0, 0.0, 1.0, 1.0);
912        assert_eq!(r.len(), 1);
913        assert_relative_eq!(r[0], -0.682_327_803_828_019_3, epsilon = 1e-12);
914    }
915
916    #[test]
917    fn cubic_with_repeated_roots() {
918        // (x - 2)^2 (x + 1) = x^3 - 3x^2 + 4
919        let r = cubic_roots(1.0, -3.0, 0.0, 4.0);
920        assert_eq!(r.len(), 2, "a repeated root is reported once");
921        assert_relative_eq!(r[0], -1.0, epsilon = 1e-9);
922        assert_relative_eq!(r[1], 2.0, epsilon = 1e-9);
923        // A triple root.
924        let t = cubic_roots(1.0, 0.0, 0.0, 0.0);
925        assert_eq!(t, vec![0.0]);
926    }
927
928    #[test]
929    fn roots_strips_leading_zeros_before_choosing_a_method() {
930        // Presented as a cubic, but with a zero cubic term: solving it as one
931        // would divide by zero.
932        let r = roots(&[-4.0, 0.0, 1.0, 0.0], 1e-12).unwrap();
933        assert_eq!(r.len(), 2);
934        assert_relative_eq!(r[0], -2.0, epsilon = 1e-12);
935        assert_relative_eq!(r[1], 2.0, epsilon = 1e-12);
936    }
937
938    #[test]
939    fn roots_of_a_quartic() {
940        // (x-1)(x-2)(x-3)(x-4) = x^4 - 10x^3 + 35x^2 - 50x + 24
941        let r = roots(&[24.0, -50.0, 35.0, -10.0, 1.0], 1e-9).unwrap();
942        assert_eq!(r.len(), 4);
943        for (got, want) in r.iter().zip([1.0, 2.0, 3.0, 4.0]) {
944            assert_relative_eq!(got, &want, epsilon = 1e-7);
945        }
946    }
947
948    #[test]
949    fn roots_of_a_high_degree_polynomial() {
950        // (x-1)(x-2)(x-3)(x-4)(x-5)
951        let r = roots(&[-120.0, 274.0, -225.0, 85.0, -15.0, 1.0], 1e-9).unwrap();
952        assert_eq!(r.len(), 5);
953        for (got, want) in r.iter().zip([1.0, 2.0, 3.0, 4.0, 5.0]) {
954            assert_relative_eq!(got, &want, epsilon = 1e-6);
955        }
956    }
957
958    #[test]
959    fn roots_degenerate_cases() {
960        assert!(roots(&[], 1e-12).is_err());
961        assert!(roots(&[0.0, 0.0], 1e-12).is_err());
962        assert_eq!(
963            roots(&[5.0], 1e-12).unwrap(),
964            Vec::<f64>::new(),
965            "a nonzero constant"
966        );
967        assert_eq!(roots(&[0.0, 1.0], 1e-12).unwrap(), vec![0.0]);
968    }
969
970    #[test]
971    fn minimize_finds_a_smooth_minimum() {
972        let s = minimize(|x| (x - 0.3) * (x - 0.3) + 1.0, -2.0, 2.0, C).unwrap();
973        assert_relative_eq!(s.value, 0.3, epsilon = 1e-7);
974        assert_relative_eq!(s.residual, 1.0, epsilon = 1e-12);
975    }
976
977    #[test]
978    fn minimize_handles_a_flat_minimum() {
979        // Quartic: the gradient vanishes to third order at the minimum, so a
980        // derivative-based method has almost nothing to follow.
981        let s = minimize(|x: f64| (x - 0.5).powi(4), -1.0, 2.0, C).unwrap();
982        assert!((s.value - 0.5).abs() < 1e-3, "landed at {}", s.value);
983        assert!(s.residual < 1e-12);
984    }
985
986    #[test]
987    fn minimize_refuses_a_malformed_bracket() {
988        assert!(minimize(|x| x, 1.0, 0.0, C).is_err());
989        assert!(minimize(|x| x, 0.0, f64::INFINITY, C).is_err());
990    }
991
992    #[test]
993    fn newton_system_solves_a_two_by_two() {
994        // x^2 + y^2 = 25, x - y = 1  ->  (4, 3)
995        let s = newton_system(
996            |v| {
997                let (x, y) = (v[0], v[1]);
998                (
999                    vec![x.mul_add(x, y * y) - 25.0, x - y - 1.0],
1000                    vec![vec![2.0 * x, 2.0 * y], vec![1.0, -1.0]],
1001                )
1002            },
1003            &[5.0, 1.0],
1004            C,
1005        )
1006        .unwrap();
1007        assert!(s.convergence.is_converged());
1008        assert_relative_eq!(s.value[0], 4.0, epsilon = 1e-10);
1009        assert_relative_eq!(s.value[1], 3.0, epsilon = 1e-10);
1010    }
1011
1012    #[test]
1013    fn newton_system_damping_survives_a_start_where_plain_newton_diverges() {
1014        // arctan is the classic case: an undamped Newton step from |x| > 1.4
1015        // overshoots to a *larger* residual, and each subsequent step is worse,
1016        // so the iteration runs away. From (5, 5) the first full step lands
1017        // near -30. Halving until the residual actually falls is what recovers
1018        // it.
1019        let s = newton_system(
1020            |v| {
1021                let (x, y) = (v[0], v[1]);
1022                (
1023                    vec![x.atan(), y.atan()],
1024                    vec![
1025                        vec![x.mul_add(x, 1.0).recip(), 0.0],
1026                        vec![0.0, y.mul_add(y, 1.0).recip()],
1027                    ],
1028                )
1029            },
1030            &[5.0, 5.0],
1031            C,
1032        )
1033        .unwrap();
1034        assert!(s.convergence.is_converged(), "{s:?}");
1035        assert!(s.value[0].abs() < 1e-9 && s.value[1].abs() < 1e-9, "{s:?}");
1036    }
1037
1038    #[test]
1039    fn newton_system_reports_a_residual_minimum_rather_than_looping() {
1040        // No root exists: x^2 + 1 is never zero. The solver must stop at the
1041        // residual's minimum and say it did not converge, rather than iterate
1042        // to its limit or present the estimate as a solution.
1043        let s = newton_system(
1044            |v| {
1045                let (x, y) = (v[0], v[1]);
1046                (
1047                    vec![x.mul_add(x, 1.0), y],
1048                    vec![vec![2.0 * x, 0.0], vec![0.0, 1.0]],
1049                )
1050            },
1051            &[2.0, 2.0],
1052            C,
1053        )
1054        .unwrap();
1055        assert!(!s.convergence.is_converged());
1056        assert!(s.residual >= 1.0, "the residual cannot go below 1 here");
1057    }
1058
1059    #[test]
1060    fn newton_system_reports_a_singular_jacobian_rather_than_looping() {
1061        let s = newton_system(
1062            |v| {
1063                (
1064                    vec![v[0] * v[0], v[1]],
1065                    vec![vec![2.0 * v[0], 0.0], vec![0.0, 0.0]],
1066                )
1067            },
1068            &[1.0, 1.0],
1069            C,
1070        );
1071        assert!(s.is_err());
1072    }
1073
1074    #[test]
1075    fn newton_system_checks_its_shapes() {
1076        let s = newton_system(|_| (vec![1.0], vec![vec![1.0, 2.0]]), &[0.0, 0.0], C);
1077        assert!(s.is_err());
1078    }
1079
1080    #[test]
1081    fn exhausted_is_reported_not_hidden() {
1082        // One iteration cannot possibly converge; the result must say so rather
1083        // than present the first guess as an answer.
1084        let s = brent(
1085            |x| x * x - 2.0,
1086            0.0,
1087            2.0,
1088            Criteria {
1089                max_iterations: 1,
1090                ..C
1091            },
1092        )
1093        .unwrap();
1094        assert_eq!(s.convergence, Convergence::Exhausted);
1095        assert!(!s.convergence.is_converged());
1096    }
1097}