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 kept separate so
5//! those algorithms are about geometry rather than about convergence.
6//!
7//! # What to reach for
8//!
9//! - A root inside a known bracket: [`brent`]. Guaranteed to converge, and
10//!   nearly as fast as Newton in practice.
11//! - A root with a known derivative and a good starting point: [`newton`],
12//!   which falls back to bisection whenever a step would leave the bracket.
13//!   Unsafeguarded Newton diverges on the configurations that matter: a
14//!   tangential intersection is exactly where the derivative vanishes.
15//! - Roots of a polynomial: [`roots`]. Closed form up to the cubic, the
16//!   quadratic written to avoid the cancellation the schoolbook formula
17//!   suffers. Companion-matrix eigenvalues above that.
18//! - A system of equations: [`newton_system`]. Surface projection is two
19//!   equations in two unknowns. Intersection marching is much the same.
20//! - A minimum without derivatives: [`minimize`].
21
22use nalgebra::{DMatrix, DVector};
23use ogeom_core::{OgeomResult, ogeom_bail};
24
25/// How a solver finished.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Convergence {
28    /// The residual fell below the requested tolerance.
29    Residual,
30    /// The step size fell below the requested tolerance.
31    Step,
32    /// The iteration limit was reached first. The result is the best estimate
33    /// found, and is *not* to be treated as a root.
34    Exhausted,
35}
36
37impl Convergence {
38    /// Whether the solver actually converged.
39    #[must_use]
40    pub const fn is_converged(self) -> bool {
41        !matches!(self, Self::Exhausted)
42    }
43}
44
45/// A solver result: the estimate, the residual there, and how it finished.
46#[derive(Debug, Clone, Copy, PartialEq)]
47pub struct Solution {
48    /// The parameter value.
49    pub value: f64,
50    /// The function's value there.
51    pub residual: f64,
52    /// How the iteration ended.
53    pub convergence: Convergence,
54    /// Iterations taken.
55    pub iterations: usize,
56}
57
58/// Stopping criteria for an iterative solver.
59#[derive(Debug, Clone, Copy, PartialEq)]
60pub struct Criteria {
61    /// Stop when `|f(x)|` falls to this.
62    pub residual: f64,
63    /// Stop when the step falls to this.
64    pub step: f64,
65    /// Give up after this many iterations.
66    pub max_iterations: usize,
67}
68
69impl Default for Criteria {
70    fn default() -> Self {
71        Self {
72            // Tight enough for geometric work in f64 without chasing the last
73            // couple of bits, which costs iterations and buys nothing.
74            residual: 1e-13,
75            step: 1e-14,
76            max_iterations: 100,
77        }
78    }
79}
80
81impl Criteria {
82    /// Criteria with a given residual tolerance and the default step limit.
83    #[must_use]
84    pub fn with_residual(residual: f64) -> Self {
85        Self {
86            residual,
87            ..Self::default()
88        }
89    }
90}
91
92/// Find a root of `f` in `[a, b]` by Brent's method.
93///
94/// Combines bisection, the secant method and inverse quadratic interpolation,
95/// taking whichever step is both safe and fast. Guaranteed to converge for a
96/// continuous function that changes sign across the bracket, and superlinear in
97/// practice, the right default when a bracket is available.
98///
99/// # Errors
100///
101/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the bracket is
102/// malformed, or if `f` does not change sign across it, which means there is no
103/// root to find by this method.
104pub fn brent<F>(mut f: F, a: f64, b: f64, criteria: Criteria) -> OgeomResult<Solution>
105where
106    F: FnMut(f64) -> f64,
107{
108    if !a.is_finite() || !b.is_finite() || a >= b {
109        ogeom_bail!(Construction, "bracket [{a}, {b}] is empty or non-finite");
110    }
111    let (mut fa, mut fb) = (f(a), f(b));
112    if fa == 0.0 {
113        return Ok(Solution {
114            value: a,
115            residual: 0.0,
116            convergence: Convergence::Residual,
117            iterations: 0,
118        });
119    }
120    if fb == 0.0 {
121        return Ok(Solution {
122            value: b,
123            residual: 0.0,
124            convergence: Convergence::Residual,
125            iterations: 0,
126        });
127    }
128    if fa * fb > 0.0 {
129        ogeom_bail!(
130            Construction,
131            "f does not change sign across [{a}, {b}]: f(a) = {fa}, f(b) = {fb}"
132        );
133    }
134
135    let (mut a, mut b) = (a, b);
136    // `b` is kept as the better estimate throughout.
137    if fa.abs() < fb.abs() {
138        core::mem::swap(&mut a, &mut b);
139        core::mem::swap(&mut fa, &mut fb);
140    }
141    let mut c = a;
142    let mut fc = fa;
143    let mut previous_step = b - a;
144    let mut used_bisection = true;
145
146    for iteration in 1..=criteria.max_iterations {
147        let mut s = if fa != fc && fb != fc {
148            // Inverse quadratic interpolation, when three distinct values allow
149            // it.
150            a * fb * fc / ((fa - fb) * (fa - fc))
151                + b * fa * fc / ((fb - fa) * (fb - fc))
152                + c * fa * fb / ((fc - fa) * (fc - fb))
153        } else {
154            b - fb * (b - a) / (fb - fa)
155        };
156
157        // Bisect instead whenever the interpolated step is outside the bracket
158        // or is not shrinking fast enough. This is what turns a fast but
159        // unreliable method into a guaranteed one.
160        let bounds = ((3.0 * a + b) / 4.0, b);
161        let outside = if bounds.0 < bounds.1 {
162            s < bounds.0 || s > bounds.1
163        } else {
164            s < bounds.1 || s > bounds.0
165        };
166        let step = (s - b).abs();
167        let stalled = if used_bisection {
168            step >= (b - c).abs() / 2.0
169        } else {
170            step >= previous_step.abs() / 2.0
171        };
172        if outside || stalled || previous_step.abs() < criteria.step {
173            s = f64::midpoint(a, b);
174            used_bisection = true;
175        } else {
176            used_bisection = false;
177        }
178
179        let fs = f(s);
180        previous_step = b - c;
181        c = b;
182        fc = fb;
183        if fa * fs < 0.0 {
184            b = s;
185            fb = fs;
186        } else {
187            a = s;
188            fa = fs;
189        }
190        if fa.abs() < fb.abs() {
191            core::mem::swap(&mut a, &mut b);
192            core::mem::swap(&mut fa, &mut fb);
193        }
194
195        if fb.abs() <= criteria.residual {
196            return Ok(Solution {
197                value: b,
198                residual: fb,
199                convergence: Convergence::Residual,
200                iterations: iteration,
201            });
202        }
203        if (b - a).abs() <= criteria.step {
204            return Ok(Solution {
205                value: b,
206                residual: fb,
207                convergence: Convergence::Step,
208                iterations: iteration,
209            });
210        }
211    }
212    Ok(Solution {
213        value: b,
214        residual: fb,
215        convergence: Convergence::Exhausted,
216        iterations: criteria.max_iterations,
217    })
218}
219
220/// Find a root of `f` near `start`, using its derivative, safeguarded by a
221/// bracket.
222///
223/// Takes a Newton step when that lands inside `[a, b]` and reduces the residual,
224/// and bisects otherwise. Plain Newton is not usable here: it diverges wherever
225/// the derivative is small, and small derivatives are precisely the tangential
226/// configurations a geometry kernel spends its time on.
227///
228/// `f` returns the value and the derivative together, since evaluating them
229/// separately usually repeats most of the work.
230///
231/// # Errors
232///
233/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the bracket is
234/// malformed or `f` does not change sign across it.
235pub fn newton<F>(mut f: F, a: f64, b: f64, start: f64, criteria: Criteria) -> OgeomResult<Solution>
236where
237    F: FnMut(f64) -> (f64, f64),
238{
239    if !a.is_finite() || !b.is_finite() || a >= b {
240        ogeom_bail!(Construction, "bracket [{a}, {b}] is empty or non-finite");
241    }
242    let (mut low, mut high) = (a, b);
243    let (fa, _) = f(low);
244    let (fb, _) = f(high);
245    if fa == 0.0 {
246        return Ok(Solution {
247            value: low,
248            residual: 0.0,
249            convergence: Convergence::Residual,
250            iterations: 0,
251        });
252    }
253    if fb == 0.0 {
254        return Ok(Solution {
255            value: high,
256            residual: 0.0,
257            convergence: Convergence::Residual,
258            iterations: 0,
259        });
260    }
261    if fa * fb > 0.0 {
262        ogeom_bail!(Construction, "f does not change sign across [{a}, {b}]");
263    }
264    // Orient so that f(low) < 0 < f(high). The bracket update is then a single
265    // comparison rather than a sign product.
266    if fa > 0.0 {
267        core::mem::swap(&mut low, &mut high);
268    }
269
270    let mut x = start.clamp(a, b);
271    let mut previous_step = (b - a).abs();
272
273    for iteration in 1..=criteria.max_iterations {
274        let (value, slope) = f(x);
275        if value.abs() <= criteria.residual {
276            return Ok(Solution {
277                value: x,
278                residual: value,
279                convergence: Convergence::Residual,
280                iterations: iteration,
281            });
282        }
283        if value < 0.0 {
284            low = x;
285        } else {
286            high = x;
287        }
288
289        let newton_step = if slope == 0.0 {
290            f64::INFINITY
291        } else {
292            value / slope
293        };
294        let candidate = x - newton_step;
295        let out_of_bracket = (candidate - low) * (candidate - high) > 0.0;
296        // A step that has not at least halved is a sign Newton is not making
297        // progress here, so fall back rather than grind.
298        let too_slow = (2.0 * newton_step).abs() > previous_step;
299
300        let next = if out_of_bracket || too_slow || !candidate.is_finite() {
301            f64::midpoint(low, high)
302        } else {
303            candidate
304        };
305        previous_step = (next - x).abs();
306        x = next;
307
308        if previous_step <= criteria.step {
309            let (residual, _) = f(x);
310            return Ok(Solution {
311                value: x,
312                residual,
313                convergence: Convergence::Step,
314                iterations: iteration,
315            });
316        }
317    }
318    let (residual, _) = f(x);
319    Ok(Solution {
320        value: x,
321        residual,
322        convergence: Convergence::Exhausted,
323        iterations: criteria.max_iterations,
324    })
325}
326
327/// The real roots of a polynomial, in increasing order.
328///
329/// `coefficients` are in ascending power order: `c[0] + c[1] x + c[2] x^2 ...`.
330/// Degrees up to three are solved in closed form. Above that the roots are
331/// the companion matrix's real eigenvalues.
332///
333/// Repeated roots are returned once each, since a geometry caller wants the
334/// distinct parameter values, and a double root (a tangency) is found
335/// although rounding moves it off the real line by half the digits.
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    // A double root's discriminant is zero only up to the rounding of the
385    // coefficients, which can leave it a hair below.
386    let rounding = 8.0 * f64::EPSILON * b.mul_add(b, (4.0 * a * c).abs());
387    if discriminant.abs() <= rounding {
388        return vec![-b / (2.0 * a)];
389    }
390    if discriminant < 0.0 {
391        return Vec::new();
392    }
393    let sqrt = discriminant.sqrt();
394    // Add magnitudes rather than subtract them, then get the other root from
395    // the product relation.
396    let q = -0.5 * (b + b.signum() * sqrt);
397    let (r1, r2) = (q / a, if q == 0.0 { 0.0 } else { c / q });
398    if r1 <= r2 { vec![r1, r2] } else { vec![r2, r1] }
399}
400
401/// Real roots of `a x^3 + b x^2 + c x + d`.
402///
403/// Depressed cubic plus the trigonometric solution in the three-real-roots
404/// case, which avoids the complex arithmetic Cardano's formula would otherwise
405/// need there.
406#[must_use]
407pub fn cubic_roots(a: f64, b: f64, c: f64, d: f64) -> Vec<f64> {
408    if a == 0.0 {
409        return quadratic_roots(b, c, d);
410    }
411    let (b, c, d) = (b / a, c / a, d / a);
412    let shift = b / 3.0;
413    // x = t - b/3 removes the quadratic term.
414    let p = shift.mul_add(-b, c);
415    let q = (2.0 / 27.0 * b * b).mul_add(b, shift.mul_add(-c, d));
416
417    let half_q = q / 2.0;
418    let third_p = p / 3.0;
419    let discriminant = half_q.mul_add(half_q, third_p * third_p * third_p);
420    // What rounding leaves in `p`, `q` and so in the discriminant: a double
421    // root's is zero only to within it.
422    let p_rounding = 8.0 * f64::EPSILON * ((shift * b).abs() + c.abs());
423    let q_rounding =
424        8.0 * f64::EPSILON * ((2.0 / 27.0 * b * b * b).abs() + (shift * c).abs() + d.abs());
425    let rounding = half_q.abs() * q_rounding
426        + third_p * third_p * p_rounding
427        + 8.0 * f64::EPSILON * (half_q * half_q + (third_p * third_p * third_p).abs());
428
429    if discriminant.abs() <= rounding {
430        if p.abs() <= p_rounding {
431            vec![-shift]
432        } else {
433            // t^3 + p t + q with a double root: 3q/p once, -3q/(2p) twice.
434            let mut r = vec![3.0 * q / p - shift, -1.5 * q / p - shift];
435            r.sort_by(|x, y| x.partial_cmp(y).unwrap_or(core::cmp::Ordering::Equal));
436            r
437        }
438    } else if discriminant > 0.0 {
439        let sqrt = discriminant.sqrt();
440        let u = (-half_q + sqrt).cbrt();
441        let v = (-half_q - sqrt).cbrt();
442        vec![u + v - shift]
443    } else {
444        // Three distinct real roots, via trigonometry.
445        let radius = (-third_p).sqrt();
446        let cos = (-half_q / (radius * radius * radius)).clamp(-1.0, 1.0);
447        let angle = cos.acos() / 3.0;
448        let scale = 2.0 * radius;
449        let tau_third = core::f64::consts::TAU / 3.0;
450        let mut r = vec![
451            scale.mul_add(angle.cos(), -shift),
452            scale.mul_add((angle - tau_third).cos(), -shift),
453            scale.mul_add((angle + tau_third).cos(), -shift),
454        ];
455        r.sort_by(|x, y| x.partial_cmp(y).unwrap_or(core::cmp::Ordering::Equal));
456        r
457    }
458}
459
460/// Real roots of a polynomial of any degree, via companion-matrix eigenvalues.
461fn companion_roots(c: &[f64], tolerance: f64) -> Vec<f64> {
462    let n = c.len() - 1;
463    let lead = c[n];
464    let mut m = DMatrix::<f64>::zeros(n, n);
465    for i in 0..n {
466        m[(i, n - 1)] = -c[i] / lead;
467        if i + 1 < n {
468            m[(i + 1, i)] = 1.0;
469        }
470    }
471    // Only the real eigenvalues are roots. Complex conjugate pairs are not,
472    // save a double root, which rounding splits into a pair half the digits
473    // off the line. Such a pair is a root where the polynomial's
474    // value is zero to rounding at its real part.
475    let value = |x: f64| -> (f64, f64) {
476        let (mut p, mut size) = (0.0_f64, 0.0_f64);
477        for &coefficient in c.iter().rev() {
478            p = p.mul_add(x, coefficient);
479            size = size.mul_add(x.abs(), coefficient.abs());
480        }
481        (p, size)
482    };
483    // The Schur iteration is capped: some companion matrices (a quartic
484    // whose roots come in pairs of opposite sign, a ray through a torus's
485    // middle) defeat its shifts and it would run on for ever. Where it does
486    // not settle the roots are found by Durand-Kerner instead.
487    let eigenvalues: Vec<nalgebra::Complex<f64>> =
488        match nalgebra::linalg::Schur::try_new(m, f64::EPSILON, 1000) {
489            Some(schur) => schur.complex_eigenvalues().iter().copied().collect(),
490            None => durand_kerner(c),
491        };
492    eigenvalues
493        .iter()
494        .filter_map(|e| {
495            let scale = e.re.abs().max(1.0);
496            if e.im.abs() <= tolerance.max(1e-9) * scale {
497                return Some(e.re);
498            }
499            if e.im.abs() > 1e-7 * scale {
500                return None;
501            }
502            let (p, size) = value(e.re);
503            (p.abs() <= 1e-10 * size).then_some(e.re)
504        })
505        .collect()
506}
507
508/// A polynomial's complex roots by Durand-Kerner iteration, from points
509/// spread on a circle the roots lie within: each root moved by the
510/// polynomial's value over the product of its distances to the others,
511/// until no move is larger than rounding, or a few hundred rounds.
512fn durand_kerner(c: &[f64]) -> Vec<nalgebra::Complex<f64>> {
513    use nalgebra::Complex;
514    let n = c.len() - 1;
515    let lead = c[n];
516    let monic: Vec<f64> = c.iter().map(|x| x / lead).collect();
517    // Every root lies within one plus the largest coefficient of the monic
518    // polynomial.
519    let radius = 1.0 + monic[..n].iter().fold(0.0_f64, |m, x| m.max(x.abs()));
520    #[allow(clippy::cast_precision_loss, reason = "a degree")]
521    let mut z: Vec<Complex<f64>> = (0..n)
522        .map(|k| Complex::from_polar(radius, 0.4 + core::f64::consts::TAU * k as f64 / n as f64))
523        .collect();
524    let value = |x: Complex<f64>| {
525        let mut p = Complex::new(1.0, 0.0);
526        for &coefficient in monic[..n].iter().rev() {
527            p = p * x + coefficient;
528        }
529        p
530    };
531    for _ in 0..500 {
532        let mut largest = 0.0_f64;
533        for i in 0..n {
534            let mut denominator = Complex::new(1.0, 0.0);
535            for j in 0..n {
536                if i != j {
537                    denominator *= z[i] - z[j];
538                }
539            }
540            if denominator.norm() == 0.0 {
541                continue;
542            }
543            let step = value(z[i]) / denominator;
544            z[i] -= step;
545            largest = largest.max(step.norm() / z[i].norm().max(1.0));
546        }
547        if largest <= f64::EPSILON * 4.0 {
548            break;
549        }
550    }
551    z
552}
553
554/// Minimize a scalar function on `[a, b]` without derivatives.
555///
556/// Brent's method again: golden-section search with parabolic interpolation
557/// wherever the parabola is well behaved. Converges for any continuous function
558/// and is not fooled by the flat regions near a minimum, where a derivative
559/// method has nothing to work with.
560///
561/// # Errors
562///
563/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the bracket is
564/// malformed.
565pub fn minimize<F>(mut f: F, a: f64, b: f64, criteria: Criteria) -> OgeomResult<Solution>
566where
567    F: FnMut(f64) -> f64,
568{
569    if !a.is_finite() || !b.is_finite() || a >= b {
570        ogeom_bail!(Construction, "bracket [{a}, {b}] is empty or non-finite");
571    }
572    // 1 - 1/phi: the golden-section step.
573    const GOLDEN: f64 = 0.381_966_011_250_105_15;
574
575    let (mut low, mut high) = (a, b);
576    let mut x = GOLDEN.mul_add(b - a, a);
577    let (mut w, mut v) = (x, x);
578    let mut fx = f(x);
579    let (mut fw, mut fv) = (fx, fx);
580    let mut step = 0.0_f64;
581    let mut previous_step = 0.0_f64;
582
583    for iteration in 1..=criteria.max_iterations {
584        let middle = f64::midpoint(low, high);
585        let tolerance = criteria.step.mul_add(x.abs(), criteria.step);
586        if (x - middle).abs() <= 2.0f64.mul_add(tolerance, -((high - low) / 2.0)) {
587            return Ok(Solution {
588                value: x,
589                residual: fx,
590                convergence: Convergence::Step,
591                iterations: iteration,
592            });
593        }
594
595        let mut use_golden = true;
596        if previous_step.abs() > tolerance {
597            // Fit a parabola through the three best points so far.
598            let r = (x - w) * (fx - fv);
599            let q = (x - v) * (fx - fw);
600            let mut p = (x - v) * q - (x - w) * r;
601            let mut q = 2.0 * (q - r);
602            if q > 0.0 {
603                p = -p;
604            }
605            q = q.abs();
606            // Accept the parabolic step only if it stays inside the bracket and
607            // is smaller than half the step before last.
608            if p.abs() < (0.5 * q * previous_step).abs() && p > q * (low - x) && p < q * (high - x)
609            {
610                step = p / q;
611                let candidate = x + step;
612                if candidate - low < 2.0 * tolerance || high - candidate < 2.0 * tolerance {
613                    step = if x < middle { tolerance } else { -tolerance };
614                }
615                use_golden = false;
616            }
617        }
618        if use_golden {
619            previous_step = if x < middle { high - x } else { low - x };
620            step = GOLDEN * previous_step;
621        }
622
623        let next = if step.abs() >= tolerance {
624            x + step
625        } else if step > 0.0 {
626            x + tolerance
627        } else {
628            x - tolerance
629        };
630        let fnext = f(next);
631
632        if fnext <= fx {
633            if next < x {
634                high = x;
635            } else {
636                low = x;
637            }
638            v = w;
639            fv = fw;
640            w = x;
641            fw = fx;
642            x = next;
643            fx = fnext;
644        } else {
645            if next < x {
646                low = next;
647            } else {
648                high = next;
649            }
650            if fnext <= fw || w == x {
651                v = w;
652                fv = fw;
653                w = next;
654                fw = fnext;
655            } else if fnext <= fv || v == x || v == w {
656                v = next;
657                fv = fnext;
658            }
659        }
660        previous_step = step;
661    }
662    Ok(Solution {
663        value: x,
664        residual: fx,
665        convergence: Convergence::Exhausted,
666        iterations: criteria.max_iterations,
667    })
668}
669
670/// The outcome of solving a system of equations.
671#[derive(Debug, Clone, PartialEq)]
672pub struct SystemSolution {
673    /// The estimate.
674    pub value: Vec<f64>,
675    /// The norm of the residual vector there.
676    pub residual: f64,
677    /// How the iteration ended.
678    pub convergence: Convergence,
679    /// Iterations taken.
680    pub iterations: usize,
681}
682
683/// Solve `f(x) = 0` for a vector `x`, by damped Newton.
684///
685/// `f` returns the residual vector and the Jacobian, row-major. The step is
686/// halved until it actually reduces the residual. Undamped Newton overshoots
687/// badly from a poor start, and a geometry caller's start is often only a rough
688/// guess from a coarse sampling.
689///
690/// Surface projection is this with two equations in two unknowns, and so is a
691/// step of a surface/surface intersection march.
692///
693/// Where no root exists the residual has a positive minimum, and no damping
694/// finds a downhill step from it. That is reported as
695/// [`Convergence::Exhausted`] with the best estimate attached. "No root here"
696/// is a useful answer, and far better than iterating to the limit.
697///
698/// # Errors
699///
700/// [`OgeomError::Dimension`](ogeom_core::OgeomError::Dimension) if the Jacobian's shape
701/// disagrees with the residual, and
702/// [`OgeomError::Numeric`](ogeom_core::OgeomError::Numeric) if the Jacobian is singular
703/// at the starting point.
704pub fn newton_system<F>(mut f: F, start: &[f64], criteria: Criteria) -> OgeomResult<SystemSolution>
705where
706    F: FnMut(&[f64]) -> (Vec<f64>, Vec<Vec<f64>>),
707{
708    let n = start.len();
709    let mut x = DVector::from_row_slice(start);
710
711    let evaluate = |x: &DVector<f64>, f: &mut F| {
712        let (r, j) = f(x.as_slice());
713        (r, j)
714    };
715
716    let (mut residual, mut jacobian) = evaluate(&x, &mut f);
717    if residual.len() != n || jacobian.len() != n || jacobian.iter().any(|row| row.len() != n) {
718        ogeom_bail!(
719            Dimension,
720            "expected a {n}-vector residual and {n}x{n} Jacobian"
721        );
722    }
723    let mut norm = residual.iter().map(|v| v * v).sum::<f64>().sqrt();
724
725    for iteration in 1..=criteria.max_iterations {
726        if norm <= criteria.residual {
727            return Ok(SystemSolution {
728                value: x.as_slice().to_vec(),
729                residual: norm,
730                convergence: Convergence::Residual,
731                iterations: iteration - 1,
732            });
733        }
734
735        let j = DMatrix::from_fn(n, n, |r, c| jacobian[r][c]);
736        let rhs = DVector::from_row_slice(&residual);
737        let Some(delta) = j.lu().solve(&rhs) else {
738            ogeom_bail!(Numeric, "Jacobian is singular after {iteration} iterations");
739        };
740
741        // Damping: keep halving until the residual actually falls. Without it,
742        // Newton happily steps past the solution and never comes back.
743        let mut scale = 1.0;
744        let mut accepted = None;
745        for _ in 0..30 {
746            let candidate = &x - &delta * scale;
747            let (r, jj) = evaluate(&candidate, &mut f);
748            let candidate_norm = r.iter().map(|v| v * v).sum::<f64>().sqrt();
749            if candidate_norm < norm || candidate_norm <= criteria.residual {
750                accepted = Some((candidate, r, jj, candidate_norm));
751                break;
752            }
753            scale *= 0.5;
754        }
755
756        let Some((next, r, jj, next_norm)) = accepted else {
757            // No downhill step exists: this is a local minimum of the residual,
758            // not a root, and saying so is more useful than iterating forever.
759            return Ok(SystemSolution {
760                value: x.as_slice().to_vec(),
761                residual: norm,
762                convergence: Convergence::Exhausted,
763                iterations: iteration,
764            });
765        };
766
767        let step = (&next - &x).norm();
768        x = next;
769        residual = r;
770        jacobian = jj;
771        norm = next_norm;
772
773        if norm <= criteria.residual {
774            return Ok(SystemSolution {
775                value: x.as_slice().to_vec(),
776                residual: norm,
777                convergence: Convergence::Residual,
778                iterations: iteration,
779            });
780        }
781        if step <= criteria.step {
782            return Ok(SystemSolution {
783                value: x.as_slice().to_vec(),
784                residual: norm,
785                convergence: Convergence::Step,
786                iterations: iteration,
787            });
788        }
789    }
790    Ok(SystemSolution {
791        value: x.as_slice().to_vec(),
792        residual: norm,
793        convergence: Convergence::Exhausted,
794        iterations: criteria.max_iterations,
795    })
796}
797
798/// A fixed-size [`newton_system`] for `N` unknowns, allocation-free.
799///
800/// The same damped iteration (halving until the residual falls, the same
801/// three verdicts), on stack arrays and a fixed-size LU: the intersectors'
802/// three- and four-unknown systems run this millions of times per model,
803/// and the general path allocates for every residual, Jacobian and step.
804///
805/// # Errors
806///
807/// [`OgeomError::Numeric`](ogeom_core::OgeomError::Numeric) if the
808/// Jacobian is singular.
809pub fn newton_system_fixed<const N: usize, F>(
810    mut f: F,
811    start: [f64; N],
812    criteria: Criteria,
813) -> OgeomResult<([f64; N], f64, Convergence, usize)>
814where
815    F: FnMut(&[f64; N]) -> ([f64; N], [[f64; N]; N]),
816{
817    // The Jacobian of the point last measured: the lazy iteration asks for
818    // it only right after measuring the same point.
819    let last = std::cell::Cell::new([[0.0; N]; N]);
820    newton_system_fixed_lazy(
821        |x| {
822            let (residual, jacobian) = f(x);
823            last.set(jacobian);
824            residual
825        },
826        |_| Some(last.get()),
827        start,
828        criteria,
829    )
830}
831
832/// [`newton_system_fixed`] with the residual and the Jacobian measured
833/// apart, the Jacobian only where a step is taken.
834///
835/// The damped search measures every trial point and keeps few, and where
836/// the Jacobian costs more than the residual (a spline surface's
837/// derivatives against its point) most of that goes on Jacobians never
838/// used. The iteration is the one [`newton_system_fixed`] runs on
839/// `|x| (residual(x), jacobian(x))`, a `None` Jacobian standing for a
840/// point that cannot be measured: an infinite residual and a zero
841/// Jacobian. So it answers the same to the bit.
842///
843/// # Errors
844///
845/// As [`newton_system_fixed`].
846pub fn newton_system_fixed_lazy<const N: usize, R, J>(
847    mut residual_at: R,
848    mut jacobian_at: J,
849    start: [f64; N],
850    criteria: Criteria,
851) -> OgeomResult<([f64; N], f64, Convergence, usize)>
852where
853    R: FnMut(&[f64; N]) -> [f64; N],
854    J: FnMut(&[f64; N]) -> Option<[[f64; N]; N]>,
855{
856    let norm_of = |r: &[f64; N]| r.iter().map(|v| v * v).sum::<f64>().sqrt();
857    let mut x = start;
858    let first = residual_at(&x);
859    let (mut residual, mut jacobian) =
860        jacobian_at(&x).map_or(([f64::INFINITY; N], [[0.0; N]; N]), |j| (first, j));
861    let mut norm = norm_of(&residual);
862    for iteration in 1..=criteria.max_iterations {
863        if norm <= criteria.residual {
864            return Ok((x, norm, Convergence::Residual, iteration - 1));
865        }
866        let Some(delta) = solve_fixed(jacobian, residual) else {
867            ogeom_bail!(Numeric, "Jacobian is singular after {iteration} iterations");
868        };
869        let mut scale = 1.0;
870        let mut accepted = None;
871        for _ in 0..30 {
872            let mut candidate = x;
873            for (value, d) in candidate.iter_mut().zip(delta.iter()) {
874                *value -= d * scale;
875            }
876            let r = residual_at(&candidate);
877            let candidate_norm = norm_of(&r);
878            // A point whose Jacobian cannot be measured is unmeasured, and
879            // no step is accepted onto it.
880            if (candidate_norm < norm || candidate_norm <= criteria.residual)
881                && let Some(jj) = jacobian_at(&candidate)
882            {
883                accepted = Some((candidate, r, jj, candidate_norm));
884                break;
885            }
886            scale *= 0.5;
887        }
888        let Some((next, r, jj, next_norm)) = accepted else {
889            return Ok((x, norm, Convergence::Exhausted, iteration));
890        };
891        let step = next
892            .iter()
893            .zip(&x)
894            .map(|(a, b)| (a - b) * (a - b))
895            .sum::<f64>()
896            .sqrt();
897        x = next;
898        residual = r;
899        jacobian = jj;
900        norm = next_norm;
901        if norm <= criteria.residual {
902            return Ok((x, norm, Convergence::Residual, iteration));
903        }
904        if step <= criteria.step {
905            return Ok((x, norm, Convergence::Step, iteration));
906        }
907    }
908    Ok((x, norm, Convergence::Exhausted, criteria.max_iterations))
909}
910
911/// `A x = b` by LU with partial pivoting, `None` at a zero pivot.
912///
913/// The arithmetic is the general solver's LU step for step: the first
914/// largest magnitude pivots, the multipliers are the column times the
915/// pivot's reciprocal, and the back substitution runs column by column from
916/// the last. So a system moved from [`newton_system`] to
917/// [`newton_system_fixed`] solves to the same bits.
918fn solve_fixed<const N: usize>(mut a: [[f64; N]; N], mut b: [f64; N]) -> Option<[f64; N]> {
919    for col in 0..N {
920        let mut pivot = col;
921        for row in col + 1..N {
922            if a[row][col].abs() > a[pivot][col].abs() {
923                pivot = row;
924            }
925        }
926        let diag = a[pivot][col];
927        if diag == 0.0 {
928            return None;
929        }
930        a.swap(col, pivot);
931        b.swap(col, pivot);
932        let inverse = 1.0 / diag;
933        let head = a[col];
934        for row in col + 1..N {
935            let factor = a[row][col] * inverse;
936            for (entry, above) in a[row].iter_mut().zip(&head).skip(col + 1) {
937                *entry -= factor * above;
938            }
939            b[row] -= factor * b[col];
940        }
941    }
942    for col in (0..N).rev() {
943        b[col] /= a[col][col];
944        let known = b[col];
945        for row in 0..col {
946            b[row] -= a[row][col] * known;
947        }
948    }
949    Some(b)
950}
951
952/// A two-unknown [`newton_system`], allocation-free.
953///
954/// The foot-point projection runs this system millions of times per real
955/// model, and the general path pays a heap allocation for every residual,
956/// Jacobian, vector and factorization of every damped step. The algorithm
957/// here is the same (damped Newton, halving until the residual falls, the
958/// same three convergence verdicts), with the two-by-two solve written out:
959/// partial pivoting is one comparison, and singularity is a vanishing
960/// pivot.
961///
962/// # Errors
963///
964/// [`OgeomError::Numeric`](ogeom_core::OgeomError::Numeric) if the Jacobian
965/// is singular.
966pub fn newton_system_2<F>(
967    mut f: F,
968    start: [f64; 2],
969    criteria: Criteria,
970) -> OgeomResult<([f64; 2], f64, Convergence, usize)>
971where
972    F: FnMut([f64; 2]) -> ([f64; 2], [[f64; 2]; 2]),
973{
974    let mut x = start;
975    let (mut residual, mut jacobian) = f(x);
976    let mut norm = residual[0].hypot(residual[1]);
977
978    for iteration in 1..=criteria.max_iterations {
979        if norm <= criteria.residual {
980            return Ok((x, norm, Convergence::Residual, iteration - 1));
981        }
982
983        // Solve J * delta = residual, partial pivoting on the first column.
984        let (row0, row1, rhs0, rhs1) = if jacobian[0][0].abs() >= jacobian[1][0].abs() {
985            (jacobian[0], jacobian[1], residual[0], residual[1])
986        } else {
987            (jacobian[1], jacobian[0], residual[1], residual[0])
988        };
989        if row0[0].abs() <= f64::EPSILON * (row1[0].abs() + row0[1].abs()).max(1.0) {
990            ogeom_bail!(Numeric, "Jacobian is singular after {iteration} iterations");
991        }
992        let factor = row1[0] / row0[0];
993        let denom = factor.mul_add(-row0[1], row1[1]);
994        if denom.abs() <= f64::EPSILON * row0[1].abs().max(1.0) {
995            ogeom_bail!(Numeric, "Jacobian is singular after {iteration} iterations");
996        }
997        let d1 = factor.mul_add(-rhs0, rhs1) / denom;
998        let d0 = d1.mul_add(-row0[1], rhs0) / row0[0];
999        let delta = [d0, d1];
1000
1001        // Damping: keep halving until the residual actually falls.
1002        let mut scale = 1.0;
1003        let mut accepted = None;
1004        for _ in 0..30 {
1005            let candidate = [
1006                delta[0].mul_add(-scale, x[0]),
1007                delta[1].mul_add(-scale, x[1]),
1008            ];
1009            let (r, jj) = f(candidate);
1010            let candidate_norm = r[0].hypot(r[1]);
1011            if candidate_norm < norm || candidate_norm <= criteria.residual {
1012                accepted = Some((candidate, r, jj, candidate_norm));
1013                break;
1014            }
1015            scale *= 0.5;
1016        }
1017        let Some((next, r, jj, next_norm)) = accepted else {
1018            return Ok((x, norm, Convergence::Exhausted, iteration));
1019        };
1020
1021        let step = (next[0] - x[0]).hypot(next[1] - x[1]);
1022        x = next;
1023        residual = r;
1024        jacobian = jj;
1025        norm = next_norm;
1026
1027        if norm <= criteria.residual {
1028            return Ok((x, norm, Convergence::Residual, iteration));
1029        }
1030        if step <= criteria.step {
1031            return Ok((x, norm, Convergence::Step, iteration));
1032        }
1033    }
1034    Ok((x, norm, Convergence::Exhausted, criteria.max_iterations))
1035}
1036
1037#[cfg(test)]
1038#[allow(clippy::unwrap_used)]
1039mod tests {
1040    use super::*;
1041
1042    /// Every value in `expected` is among `found`, to the precision a double
1043    /// root keeps (half the digits).
1044    fn has_each(found: &[f64], expected: &[f64]) {
1045        for e in expected {
1046            assert!(
1047                found
1048                    .iter()
1049                    .any(|f| (f - e).abs() <= 1e-6 * e.abs().max(1.0)),
1050                "{e} missing from {found:?}"
1051            );
1052        }
1053    }
1054
1055    /// A quartic whose roots pair off by sign (a ray through a torus's
1056    /// middle) comes back, where the Schur iteration alone ran on for ever.
1057    #[test]
1058    fn a_quartic_with_roots_paired_by_sign_comes_back() {
1059        // (t^2 - 4)(t^2 - 16) = t^4 - 20 t^2 + 64.
1060        let found = roots(&[64.0, 0.0, -20.0, 0.0, 1.0], 1e-12).unwrap();
1061        assert_eq!(found.len(), 4, "{found:?}");
1062        for (got, want) in found.iter().zip([-4.0, -2.0, 2.0, 4.0]) {
1063            assert!((got - want).abs() < 1e-9, "{found:?}");
1064        }
1065        // And one with no real root at all.
1066        assert!(
1067            roots(&[64.0, 0.0, 4.0, 0.0, 1.0], 1e-12)
1068                .unwrap()
1069                .is_empty()
1070        );
1071    }
1072
1073    #[test]
1074    fn double_roots_survive_rounding() {
1075        for r in [0.1, 0.3, 0.7, 1.1, 3.3, 1000.0 / 3.0, -2.9, 1e-3 / 7.0] {
1076            // (x - r)^2
1077            has_each(&quadratic_roots(1.0, -2.0 * r, r * r), &[r]);
1078            // (x - 1)(x - r)^2 = x^3 - (1 + 2r) x^2 + (2r + r^2) x - r^2
1079            has_each(
1080                &cubic_roots(1.0, -(1.0 + 2.0 * r), 2.0f64.mul_add(r, r * r), -(r * r)),
1081                &[1.0, r],
1082            );
1083            // (x - 2)(x + 1)(x - r)^2, through the general solver.
1084            let quadratic = [r * r, -2.0 * r, 1.0];
1085            let pair = [-2.0, -1.0, 1.0];
1086            let mut quartic = [0.0; 5];
1087            for (i, a) in quadratic.iter().enumerate() {
1088                for (j, b) in pair.iter().enumerate() {
1089                    quartic[i + j] += a * b;
1090                }
1091            }
1092            has_each(&roots(&quartic, 1e-12).unwrap(), &[-1.0, 2.0, r]);
1093        }
1094    }
1095    use approx::assert_relative_eq;
1096
1097    const C: Criteria = Criteria {
1098        residual: 1e-13,
1099        step: 1e-14,
1100        max_iterations: 100,
1101    };
1102
1103    #[test]
1104    fn brent_finds_a_simple_root() {
1105        let s = brent(|x| x * x - 2.0, 0.0, 2.0, C).unwrap();
1106        assert!(s.convergence.is_converged());
1107        assert_relative_eq!(s.value, core::f64::consts::SQRT_2, epsilon = 1e-12);
1108    }
1109
1110    #[test]
1111    fn brent_handles_a_root_at_a_bracket_end() {
1112        let s = brent(|x| x, -1.0, 0.0, C).unwrap();
1113        assert_relative_eq!(s.value, 0.0);
1114        assert_eq!(s.iterations, 0);
1115    }
1116
1117    #[test]
1118    fn brent_refuses_a_bracket_without_a_sign_change() {
1119        assert!(brent(|x| x * x + 1.0, -1.0, 1.0, C).is_err());
1120        assert!(brent(|x| x, 1.0, 0.0, C).is_err(), "reversed bracket");
1121        assert!(brent(|x| x, 0.0, f64::NAN, C).is_err());
1122    }
1123
1124    #[test]
1125    fn brent_converges_on_a_function_that_defeats_the_secant_method() {
1126        // Very flat near the root, then steep: pure secant crawls, bisection
1127        // alone is slow, and the hybrid must do better than either.
1128        let s = brent(|x| x.powi(15) - 0.5, 0.0, 2.0, C).unwrap();
1129        assert!(s.convergence.is_converged());
1130        assert!(s.residual.abs() < 1e-12);
1131        assert!(s.iterations < 60, "took {} iterations", s.iterations);
1132    }
1133
1134    #[test]
1135    fn newton_converges_faster_than_bisection_when_it_can() {
1136        let s = newton(|x| (x * x - 2.0, 2.0 * x), 0.5, 2.0, 1.0, C).unwrap();
1137        assert!(s.convergence.is_converged());
1138        assert_relative_eq!(s.value, core::f64::consts::SQRT_2, epsilon = 1e-12);
1139        assert!(s.iterations < 12, "took {} iterations", s.iterations);
1140    }
1141
1142    #[test]
1143    fn newton_survives_a_vanishing_derivative() {
1144        // f(x) = x^3 has f'(0) = 0. Unsafeguarded Newton stalls. The bisection
1145        // fallback must carry it through.
1146        let s = newton(|x| (x * x * x, 3.0 * x * x), -1.0, 2.0, 1.9, C).unwrap();
1147        assert!(s.value.abs() < 1e-4, "landed at {}", s.value);
1148    }
1149
1150    #[test]
1151    fn newton_survives_a_terrible_starting_point() {
1152        for start in [-0.999_f64, 0.0, 1.999, 1.0] {
1153            let s = newton(|x| (x * x - 2.0, 2.0 * x), -1.0, 2.0, start, C).unwrap();
1154            assert!(
1155                (s.value - core::f64::consts::SQRT_2).abs() < 1e-9,
1156                "start {start} gave {}",
1157                s.value
1158            );
1159        }
1160    }
1161
1162    #[test]
1163    fn quadratic_roots_stay_accurate_when_the_roots_are_far_apart() {
1164        // x^2 - (1e8 + 1e-8) x + 1 has roots 1e8 and 1e-8. The schoolbook
1165        // formula computes the small one as a difference of nearly equal
1166        // numbers and loses almost all of it.
1167        let r = quadratic_roots(1.0, -(1e8 + 1e-8), 1.0);
1168        assert_eq!(r.len(), 2);
1169        assert_relative_eq!(r[0], 1e-8, max_relative = 1e-10);
1170        assert_relative_eq!(r[1], 1e8, max_relative = 1e-14);
1171    }
1172
1173    #[test]
1174    fn quadratic_edge_cases() {
1175        assert_eq!(
1176            quadratic_roots(1.0, 0.0, 1.0),
1177            Vec::<f64>::new(),
1178            "no real roots"
1179        );
1180        assert_eq!(quadratic_roots(1.0, -2.0, 1.0), vec![1.0], "double root");
1181        assert_eq!(
1182            quadratic_roots(0.0, 2.0, -4.0),
1183            vec![2.0],
1184            "degenerates to linear"
1185        );
1186        assert_eq!(quadratic_roots(0.0, 0.0, 1.0), Vec::<f64>::new());
1187        let r = quadratic_roots(1.0, 0.0, -4.0);
1188        assert_relative_eq!(r[0], -2.0);
1189        assert_relative_eq!(r[1], 2.0);
1190    }
1191
1192    #[test]
1193    fn cubic_with_three_real_roots() {
1194        // (x + 3)(x - 1)(x - 2) = x^3 - 7x + 6
1195        let r = cubic_roots(1.0, 0.0, -7.0, 6.0);
1196        assert_eq!(r.len(), 3);
1197        assert_relative_eq!(r[0], -3.0, epsilon = 1e-12);
1198        assert_relative_eq!(r[1], 1.0, epsilon = 1e-12);
1199        assert_relative_eq!(r[2], 2.0, epsilon = 1e-12);
1200    }
1201
1202    #[test]
1203    fn cubic_with_one_real_root() {
1204        // x^3 + x + 1 has a single real root near -0.6823
1205        let r = cubic_roots(1.0, 0.0, 1.0, 1.0);
1206        assert_eq!(r.len(), 1);
1207        assert_relative_eq!(r[0], -0.682_327_803_828_019_3, epsilon = 1e-12);
1208    }
1209
1210    #[test]
1211    fn cubic_with_repeated_roots() {
1212        // (x - 2)^2 (x + 1) = x^3 - 3x^2 + 4
1213        let r = cubic_roots(1.0, -3.0, 0.0, 4.0);
1214        assert_eq!(r.len(), 2, "a repeated root is reported once");
1215        assert_relative_eq!(r[0], -1.0, epsilon = 1e-9);
1216        assert_relative_eq!(r[1], 2.0, epsilon = 1e-9);
1217        // A triple root.
1218        let t = cubic_roots(1.0, 0.0, 0.0, 0.0);
1219        assert_eq!(t, vec![0.0]);
1220    }
1221
1222    #[test]
1223    fn roots_strips_leading_zeros_before_choosing_a_method() {
1224        // Presented as a cubic, but with a zero cubic term: solving it as one
1225        // would divide by zero.
1226        let r = roots(&[-4.0, 0.0, 1.0, 0.0], 1e-12).unwrap();
1227        assert_eq!(r.len(), 2);
1228        assert_relative_eq!(r[0], -2.0, epsilon = 1e-12);
1229        assert_relative_eq!(r[1], 2.0, epsilon = 1e-12);
1230    }
1231
1232    #[test]
1233    fn roots_of_a_quartic() {
1234        // (x-1)(x-2)(x-3)(x-4) = x^4 - 10x^3 + 35x^2 - 50x + 24
1235        let r = roots(&[24.0, -50.0, 35.0, -10.0, 1.0], 1e-9).unwrap();
1236        assert_eq!(r.len(), 4);
1237        for (got, want) in r.iter().zip([1.0, 2.0, 3.0, 4.0]) {
1238            assert_relative_eq!(got, &want, epsilon = 1e-7);
1239        }
1240    }
1241
1242    #[test]
1243    fn roots_of_a_high_degree_polynomial() {
1244        // (x-1)(x-2)(x-3)(x-4)(x-5)
1245        let r = roots(&[-120.0, 274.0, -225.0, 85.0, -15.0, 1.0], 1e-9).unwrap();
1246        assert_eq!(r.len(), 5);
1247        for (got, want) in r.iter().zip([1.0, 2.0, 3.0, 4.0, 5.0]) {
1248            assert_relative_eq!(got, &want, epsilon = 1e-6);
1249        }
1250    }
1251
1252    #[test]
1253    fn roots_degenerate_cases() {
1254        assert!(roots(&[], 1e-12).is_err());
1255        assert!(roots(&[0.0, 0.0], 1e-12).is_err());
1256        assert_eq!(
1257            roots(&[5.0], 1e-12).unwrap(),
1258            Vec::<f64>::new(),
1259            "a nonzero constant"
1260        );
1261        assert_eq!(roots(&[0.0, 1.0], 1e-12).unwrap(), vec![0.0]);
1262    }
1263
1264    #[test]
1265    fn minimize_finds_a_smooth_minimum() {
1266        let s = minimize(|x| (x - 0.3) * (x - 0.3) + 1.0, -2.0, 2.0, C).unwrap();
1267        assert_relative_eq!(s.value, 0.3, epsilon = 1e-7);
1268        assert_relative_eq!(s.residual, 1.0, epsilon = 1e-12);
1269    }
1270
1271    #[test]
1272    fn minimize_handles_a_flat_minimum() {
1273        // Quartic: the gradient vanishes to third order at the minimum, so a
1274        // derivative-based method has almost nothing to follow.
1275        let s = minimize(|x: f64| (x - 0.5).powi(4), -1.0, 2.0, C).unwrap();
1276        assert!((s.value - 0.5).abs() < 1e-3, "landed at {}", s.value);
1277        assert!(s.residual < 1e-12);
1278    }
1279
1280    #[test]
1281    fn minimize_refuses_a_malformed_bracket() {
1282        assert!(minimize(|x| x, 1.0, 0.0, C).is_err());
1283        assert!(minimize(|x| x, 0.0, f64::INFINITY, C).is_err());
1284    }
1285
1286    #[test]
1287    fn newton_system_solves_a_two_by_two() {
1288        // x^2 + y^2 = 25, x - y = 1  ->  (4, 3)
1289        let s = newton_system(
1290            |v| {
1291                let (x, y) = (v[0], v[1]);
1292                (
1293                    vec![x.mul_add(x, y * y) - 25.0, x - y - 1.0],
1294                    vec![vec![2.0 * x, 2.0 * y], vec![1.0, -1.0]],
1295                )
1296            },
1297            &[5.0, 1.0],
1298            C,
1299        )
1300        .unwrap();
1301        assert!(s.convergence.is_converged());
1302        assert_relative_eq!(s.value[0], 4.0, epsilon = 1e-10);
1303        assert_relative_eq!(s.value[1], 3.0, epsilon = 1e-10);
1304    }
1305
1306    #[test]
1307    fn newton_system_damping_survives_a_start_where_plain_newton_diverges() {
1308        // arctan is the classic case: an undamped Newton step from |x| > 1.4
1309        // overshoots to a *larger* residual, and each subsequent step is worse,
1310        // so the iteration runs away. From (5, 5) the first full step lands
1311        // near -30. Halving until the residual actually falls is what recovers
1312        // it.
1313        let s = newton_system(
1314            |v| {
1315                let (x, y) = (v[0], v[1]);
1316                (
1317                    vec![x.atan(), y.atan()],
1318                    vec![
1319                        vec![x.mul_add(x, 1.0).recip(), 0.0],
1320                        vec![0.0, y.mul_add(y, 1.0).recip()],
1321                    ],
1322                )
1323            },
1324            &[5.0, 5.0],
1325            C,
1326        )
1327        .unwrap();
1328        assert!(s.convergence.is_converged(), "{s:?}");
1329        assert!(s.value[0].abs() < 1e-9 && s.value[1].abs() < 1e-9, "{s:?}");
1330    }
1331
1332    #[test]
1333    fn newton_system_reports_a_residual_minimum_rather_than_looping() {
1334        // No root exists: x^2 + 1 is never zero. The solver must stop at the
1335        // residual's minimum and say it did not converge, rather than iterate
1336        // to its limit or present the estimate as a solution.
1337        let s = newton_system(
1338            |v| {
1339                let (x, y) = (v[0], v[1]);
1340                (
1341                    vec![x.mul_add(x, 1.0), y],
1342                    vec![vec![2.0 * x, 0.0], vec![0.0, 1.0]],
1343                )
1344            },
1345            &[2.0, 2.0],
1346            C,
1347        )
1348        .unwrap();
1349        assert!(!s.convergence.is_converged());
1350        assert!(s.residual >= 1.0, "the residual cannot go below 1 here");
1351    }
1352
1353    #[test]
1354    fn newton_system_reports_a_singular_jacobian_rather_than_looping() {
1355        let s = newton_system(
1356            |v| {
1357                (
1358                    vec![v[0] * v[0], v[1]],
1359                    vec![vec![2.0 * v[0], 0.0], vec![0.0, 0.0]],
1360                )
1361            },
1362            &[1.0, 1.0],
1363            C,
1364        );
1365        assert!(s.is_err());
1366    }
1367
1368    #[test]
1369    fn newton_system_checks_its_shapes() {
1370        let s = newton_system(|_| (vec![1.0], vec![vec![1.0, 2.0]]), &[0.0, 0.0], C);
1371        assert!(s.is_err());
1372    }
1373
1374    #[test]
1375    fn exhausted_is_reported_not_hidden() {
1376        // One iteration cannot possibly converge. The result must say so rather
1377        // than present the first guess as an answer.
1378        let s = brent(
1379            |x| x * x - 2.0,
1380            0.0,
1381            2.0,
1382            Criteria {
1383                max_iterations: 1,
1384                ..C
1385            },
1386        )
1387        .unwrap();
1388        assert_eq!(s.convergence, Convergence::Exhausted);
1389        assert!(!s.convergence.is_converged());
1390    }
1391
1392    /// The fixed-size solver lands on the general one's bits: the same
1393    /// iterates, verdict and count, on nonlinear systems of three, four and
1394    /// five unknowns whose Jacobians need pivoting and have ties for it.
1395    #[test]
1396    fn the_fixed_solver_matches_the_general_one_to_the_bit() {
1397        fn check<const N: usize>(start: [f64; N]) {
1398            let system = |x: &[f64]| {
1399                let mut r = vec![0.0; N];
1400                let mut j = vec![vec![0.0; N]; N];
1401                for i in 0..N {
1402                    let k = (i + 1) % N;
1403                    #[allow(clippy::cast_precision_loss)]
1404                    let weight = 1.0 + i as f64 * 0.37;
1405                    r[i] = x[i].mul_add(x[k], -weight) + x[k].sin() * 0.3;
1406                    j[i][i] += x[k];
1407                    j[i][k] += x[i] + x[k].cos() * 0.3;
1408                }
1409                (r, j)
1410            };
1411            let criteria = Criteria {
1412                residual: 1e-14,
1413                step: 1e-15,
1414                max_iterations: 60,
1415            };
1416            let general = newton_system(system, &start, criteria).unwrap();
1417            let fixed = newton_system_fixed(
1418                |x: &[f64; N]| {
1419                    let (r, j) = system(x);
1420                    let mut rows = [[0.0; N]; N];
1421                    for (to, from) in rows.iter_mut().zip(&j) {
1422                        to.copy_from_slice(from);
1423                    }
1424                    (r.try_into().unwrap(), rows)
1425                },
1426                start,
1427                criteria,
1428            )
1429            .unwrap();
1430            assert_eq!(general.value, fixed.0.to_vec());
1431            assert_eq!(general.residual.to_bits(), fixed.1.to_bits());
1432            assert_eq!(general.convergence, fixed.2);
1433            assert_eq!(general.iterations, fixed.3);
1434        }
1435        check([1.0, 1.0, 1.0]);
1436        check([0.5, 2.0, -1.0, 1.5]);
1437        check([1.0, -1.0, 1.0, -1.0, 2.0]);
1438        check([3.0, 0.2, 0.7, 1.1, 0.4]);
1439    }
1440}