Skip to main content

symplex/domains/
optimize.rs

1//! Numerical optimisation and root bracketing.
2//!
3//! Plain `f64` routines that need nothing but a closure:
4//!
5//! | Task | Routines |
6//! |------|----------|
7//! | Bracketed root finding | [`brent_root`] (Brent–Dekker), [`bisect`] |
8//! | Root polishing from a point | [`newton_root`] |
9//! | Derivative-free local minimisation | [`nelder_mead`] |
10//! | Bracketed scalar minimisation | [`minimize_scalar`] (Brent), [`golden_section`] |
11//! | Global minimisation in a box | [`differential_evolution`] (DE/rand/1/bin + Nelder–Mead polish) |
12//! | Least-squares fitting | [`poly_fit`], [`poly_fit_exact`], [`linear_fit`] |
13//! | Helpers | [`trapezoid`], [`eval_poly`] |
14//!
15//! and convenience methods on [`Ex`] that compile an expression with
16//! [`Ex::compile`] and hand the resulting closure to the matching routine:
17//! [`Ex::find_root_bracket`], [`Ex::minimize_numeric`],
18//! [`Ex::minimize_scalar_numeric`], [`Ex::minimize_global_numeric`] and
19//! [`Ex::poly_fit_points`] (exact rational least squares).
20//!
21//! # Conventions
22//!
23//! * Every routine is deterministic — [`differential_evolution`] draws its
24//!   random numbers from a local SplitMix64 generator seeded by
25//!   [`DeOpts::seed`] — and bounded by an explicit iteration budget.
26//! * Nothing panics.  Bad input (empty vectors, a bracket without a sign
27//!   change, non-finite bounds, `degree ≥ len`, …) is reported as
28//!   [`SymplexError::InvalidArgument`]; running out of iterations or hitting
29//!   a non-finite function value is [`SymplexError::ComputationFailed`].
30//!   The minimisers that return a [`MinimizeResult`] report an exhausted
31//!   budget through [`MinimizeResult::converged`] instead of an error, so
32//!   the best point found is never thrown away.
33//! * Polynomial coefficients are always in **ascending** degree:
34//!   `[c₀, c₁, …, c_d]` represents `c₀ + c₁·x + … + c_d·x^d`.
35//!
36//! ```
37//! use symplex::optimize::{brent_root, nelder_mead, MinimizeOpts, RootOpts};
38//!
39//! // √2 as the root of x² − 2 on [0, 2].
40//! let r = brent_root(|x| x * x - 2.0, 0.0, 2.0, &RootOpts::default()).unwrap();
41//! assert!((r - 2f64.sqrt()).abs() < 1e-12);
42//!
43//! // Minimum of (x − 1)² + (y + 2)² by Nelder–Mead.
44//! let bowl = |p: &[f64]| (p[0] - 1.0).powi(2) + (p[1] + 2.0).powi(2);
45//! let m = nelder_mead(bowl, &[0.0, 0.0], &MinimizeOpts::default()).unwrap();
46//! assert!(m.converged);
47//! assert!((m.x[0] - 1.0).abs() < 1e-6 && (m.x[1] + 2.0).abs() < 1e-6);
48//! ```
49
50use crate::api::context::Context;
51use crate::api::expr::Ex;
52use crate::base::errors::SymplexError;
53use crate::base::node::ExprNode;
54use crate::output::lambdify::CompiledFn;
55use num_bigint::BigInt;
56use num_rational::Ratio;
57use num_traits::{One, Zero};
58
59// ═══════════════════════════════════════════════════════════════════════════
60// Shared helpers
61// ═══════════════════════════════════════════════════════════════════════════
62
63fn invalid(operation: &'static str, reason: String) -> SymplexError {
64    SymplexError::invalid_argument(operation, reason)
65}
66
67fn failed(operation: &'static str, reason: String) -> SymplexError {
68    SymplexError::computation_failed(operation, reason)
69}
70
71/// `NaN` objective values are treated as "worse than anything" so that a
72/// minimiser steps away from them instead of propagating the NaN.
73fn nan_to_inf(v: f64) -> f64 {
74    if v.is_nan() { f64::INFINITY } else { v }
75}
76
77fn check_endpoints(op: &'static str, a: f64, b: f64) -> Result<(), SymplexError> {
78    if !a.is_finite() || !b.is_finite() {
79        return Err(invalid(
80            op,
81            format!("interval endpoints must be finite, got [{a}, {b}]"),
82        ));
83    }
84    Ok(())
85}
86
87/// Validate an interval for the scalar minimisers: finite endpoints with
88/// positive width.  A reversed interval is accepted and returned ordered.
89fn check_interval(op: &'static str, a: f64, b: f64) -> Result<(f64, f64), SymplexError> {
90    check_endpoints(op, a, b)?;
91    if a == b {
92        return Err(invalid(
93            op,
94            format!("interval must have positive width, got [{a}, {b}]"),
95        ));
96    }
97    Ok(if a < b { (a, b) } else { (b, a) })
98}
99
100// ═══════════════════════════════════════════════════════════════════════════
101// Root finding
102// ═══════════════════════════════════════════════════════════════════════════
103
104/// Options for the scalar root finders [`brent_root`], [`bisect`] and
105/// [`newton_root`].
106///
107/// ```
108/// use symplex::optimize::RootOpts;
109///
110/// let opts = RootOpts::default();
111/// assert_eq!(opts.xtol, 2e-12);
112/// assert_eq!(opts.rtol, 4.0 * f64::EPSILON);
113/// assert_eq!(opts.max_iter, 100);
114/// ```
115#[derive(Clone, Debug, PartialEq)]
116pub struct RootOpts {
117    /// Absolute tolerance on the root location (default `2e-12`).
118    pub xtol: f64,
119    /// Relative tolerance on the root location (default `4·ε`).
120    ///
121    /// The bracketing methods stop once the bracket width is at most
122    /// `xtol + rtol·|x|`; Newton stops once the step is that small.
123    pub rtol: f64,
124    /// Maximum number of iterations (default `100`).  Each iteration costs
125    /// one function evaluation (plus one derivative evaluation for Newton);
126    /// the bracketing methods also evaluate the two endpoints up front.
127    pub max_iter: usize,
128}
129
130impl Default for RootOpts {
131    fn default() -> Self {
132        Self {
133            xtol: 2e-12,
134            rtol: 4.0 * f64::EPSILON,
135            max_iter: 100,
136        }
137    }
138}
139
140fn check_root_opts(op: &'static str, opts: &RootOpts) -> Result<(), SymplexError> {
141    let bad = |t: f64| t < 0.0 || !t.is_finite();
142    if bad(opts.xtol) || bad(opts.rtol) {
143        return Err(invalid(
144            op,
145            format!(
146                "tolerances must be finite and non-negative, got xtol = {}, rtol = {}",
147                opts.xtol, opts.rtol
148            ),
149        ));
150    }
151    Ok(())
152}
153
154/// Validate a root bracket.  `Ok(Some(x))` when an endpoint is an exact
155/// zero, `Ok(None)` for a proper sign change.
156fn check_bracket(
157    op: &'static str,
158    a: f64,
159    b: f64,
160    fa: f64,
161    fb: f64,
162) -> Result<Option<f64>, SymplexError> {
163    if !fa.is_finite() || !fb.is_finite() {
164        return Err(invalid(
165            op,
166            format!(
167                "function is not finite at the bracket endpoints: f({a}) = {fa}, f({b}) = {fb}"
168            ),
169        ));
170    }
171    if fa == 0.0 {
172        return Ok(Some(a));
173    }
174    if fb == 0.0 {
175        return Ok(Some(b));
176    }
177    if (fa > 0.0) == (fb > 0.0) {
178        return Err(invalid(
179            op,
180            format!("f(a) and f(b) must have opposite signs: f({a}) = {fa}, f({b}) = {fb}"),
181        ));
182    }
183    Ok(None)
184}
185
186/// Find a root of `f` in the bracket `[a, b]` by the Brent–Dekker method.
187///
188/// Each step chooses between inverse quadratic interpolation, the secant
189/// step and bisection, so convergence is superlinear on smooth functions
190/// while never being slower than bisection.  The bracket must satisfy
191/// `f(a)·f(b) < 0`; an exact zero at an endpoint is returned immediately.
192/// The result is within `xtol + rtol·|x|` of a sign change of `f`.
193///
194/// # Errors
195///
196/// * [`SymplexError::InvalidArgument`] if an endpoint or the function value
197///   there is not finite, if `f(a)` and `f(b)` have the same sign, or if the
198///   tolerances are negative.
199/// * [`SymplexError::ComputationFailed`] if `f` returns a non-finite value
200///   inside the bracket or the tolerance is not met within
201///   [`RootOpts::max_iter`] iterations.
202///
203/// # Examples
204///
205/// ```
206/// use symplex::optimize::{brent_root, RootOpts};
207///
208/// let root = brent_root(|x| x.cos() - x, 0.0, 1.0, &RootOpts::default()).unwrap();
209/// assert!((root.cos() - root).abs() < 1e-12);
210///
211/// // No sign change → error, not a bogus answer.
212/// assert!(brent_root(|x| x * x + 1.0, -1.0, 1.0, &RootOpts::default()).is_err());
213/// ```
214pub fn brent_root(
215    f: impl Fn(f64) -> f64,
216    a: f64,
217    b: f64,
218    opts: &RootOpts,
219) -> Result<f64, SymplexError> {
220    const OP: &str = "brent_root";
221    check_root_opts(OP, opts)?;
222    check_endpoints(OP, a, b)?;
223    let (mut a, mut b) = (a, b);
224    let (mut fa, mut fb) = (f(a), f(b));
225    if let Some(root) = check_bracket(OP, a, b, fa, fb)? {
226        return Ok(root);
227    }
228    // Invariant: `b` is the best iterate, `c` brackets the root with `b`,
229    // `a` is the previous iterate; `d` is the last step, `e` the one before.
230    let mut c = a;
231    let mut fc = fa;
232    let mut d = b - a;
233    let mut e = d;
234    for _ in 0..opts.max_iter {
235        if (fb > 0.0) == (fc > 0.0) {
236            c = a;
237            fc = fa;
238            d = b - a;
239            e = d;
240        }
241        if fc.abs() < fb.abs() {
242            a = b;
243            b = c;
244            c = a;
245            fa = fb;
246            fb = fc;
247            fc = fa;
248        }
249        let tol1 = 0.5 * (opts.xtol + opts.rtol * b.abs());
250        let xm = 0.5 * (c - b);
251        if xm.abs() <= tol1 || fb == 0.0 {
252            return Ok(b);
253        }
254        if e.abs() >= tol1 && fa.abs() > fb.abs() {
255            // Try interpolation: secant if only two points, else inverse
256            // quadratic through (a, fa), (b, fb), (c, fc).
257            let s = fb / fa;
258            let (mut p, mut q) = if a == c {
259                (2.0 * xm * s, 1.0 - s)
260            } else {
261                let q = fa / fc;
262                let r = fb / fc;
263                (
264                    s * (2.0 * xm * q * (q - r) - (b - a) * (r - 1.0)),
265                    (q - 1.0) * (r - 1.0) * (s - 1.0),
266                )
267            };
268            if p > 0.0 {
269                q = -q;
270            }
271            p = p.abs();
272            let min1 = 3.0 * xm * q - (tol1 * q).abs();
273            let min2 = (e * q).abs();
274            if 2.0 * p < min1.min(min2) {
275                e = d;
276                d = p / q;
277            } else {
278                d = xm;
279                e = d;
280            }
281        } else {
282            d = xm;
283            e = d;
284        }
285        a = b;
286        fa = fb;
287        b += if d.abs() > tol1 { d } else { tol1.copysign(xm) };
288        fb = f(b);
289        if !fb.is_finite() {
290            return Err(failed(OP, format!("f({b}) = {fb} is not finite")));
291        }
292    }
293    Err(failed(
294        OP,
295        format!(
296            "did not converge within {} iterations; bracket [{}, {}] has width {:.3e}",
297            opts.max_iter,
298            b.min(c),
299            b.max(c),
300            (c - b).abs()
301        ),
302    ))
303}
304
305/// Find a root of `f` in the bracket `[a, b]` by bisection.
306///
307/// Linear convergence (one bit per iteration), but bullet-proof: only the
308/// sign of `f` is used.  The same bracket rules and error conditions as
309/// [`brent_root`] apply.  With the default `max_iter = 100` the method can
310/// resolve any bracket down to the default tolerance.
311///
312/// # Examples
313///
314/// ```
315/// use symplex::optimize::{bisect, RootOpts};
316///
317/// let r = bisect(|x| x * x - 2.0, 0.0, 2.0, &RootOpts::default()).unwrap();
318/// assert!((r - 2f64.sqrt()).abs() < 1e-11);
319/// ```
320pub fn bisect(
321    f: impl Fn(f64) -> f64,
322    a: f64,
323    b: f64,
324    opts: &RootOpts,
325) -> Result<f64, SymplexError> {
326    const OP: &str = "bisect";
327    check_root_opts(OP, opts)?;
328    check_endpoints(OP, a, b)?;
329    let (fa, fb) = (f(a), f(b));
330    if let Some(root) = check_bracket(OP, a, b, fa, fb)? {
331        return Ok(root);
332    }
333    let (mut lo, mut hi, mut flo) = (a, b, fa);
334    for _ in 0..opts.max_iter {
335        let mid = lo + 0.5 * (hi - lo);
336        let fm = f(mid);
337        if !fm.is_finite() {
338            return Err(failed(OP, format!("f({mid}) = {fm} is not finite")));
339        }
340        if fm == 0.0 {
341            return Ok(mid);
342        }
343        if (fm > 0.0) == (flo > 0.0) {
344            lo = mid;
345            flo = fm;
346        } else {
347            hi = mid;
348        }
349        let mid = lo + 0.5 * (hi - lo);
350        if (hi - lo).abs() <= opts.xtol + opts.rtol * mid.abs() {
351            return Ok(mid);
352        }
353    }
354    Err(failed(
355        OP,
356        format!(
357            "did not converge within {} iterations; bracket [{}, {}] has width {:.3e}",
358            opts.max_iter,
359            lo.min(hi),
360            lo.max(hi),
361            (hi - lo).abs()
362        ),
363    ))
364}
365
366/// Newton's method for a root of `f` starting from `x0`, using the
367/// derivative `df`.
368///
369/// Stops when the Newton step is smaller than `xtol + rtol·|x|` or `f(x)`
370/// is exactly zero.  Divergence is detected and reported instead of
371/// looping: a non-finite iterate or function value, a vanishing (or
372/// non-finite) derivative, and exhaustion of the iteration budget all
373/// yield [`SymplexError::ComputationFailed`].
374///
375/// # Errors
376///
377/// * [`SymplexError::InvalidArgument`] if `x0` is not finite or the
378///   tolerances are negative.
379/// * [`SymplexError::ComputationFailed`] on divergence or non-convergence.
380///
381/// # Examples
382///
383/// ```
384/// use symplex::optimize::{newton_root, RootOpts};
385///
386/// let r = newton_root(|x| x * x * x - 2.0, |x| 3.0 * x * x, 1.0, &RootOpts::default()).unwrap();
387/// assert!((r - 2f64.cbrt()).abs() < 1e-12);
388///
389/// // atan(x) from x₀ = 2 diverges: the iterates blow up and the call fails cleanly.
390/// let d = newton_root(f64::atan, |x| 1.0 / (1.0 + x * x), 2.0, &RootOpts::default());
391/// assert!(d.is_err());
392/// ```
393pub fn newton_root(
394    f: impl Fn(f64) -> f64,
395    df: impl Fn(f64) -> f64,
396    x0: f64,
397    opts: &RootOpts,
398) -> Result<f64, SymplexError> {
399    const OP: &str = "newton_root";
400    check_root_opts(OP, opts)?;
401    if !x0.is_finite() {
402        return Err(invalid(
403            OP,
404            format!("initial guess must be finite, got {x0}"),
405        ));
406    }
407    let mut x = x0;
408    for _ in 0..opts.max_iter {
409        let fx = f(x);
410        if !fx.is_finite() {
411            return Err(failed(
412                OP,
413                format!("f({x:e}) = {fx:e} is not finite; the iteration diverged"),
414            ));
415        }
416        if fx == 0.0 {
417            return Ok(x);
418        }
419        let dfx = df(x);
420        if !dfx.is_finite() || dfx == 0.0 {
421            return Err(failed(
422                OP,
423                format!("derivative f'({x:e}) = {dfx:e} vanishes or is not finite"),
424            ));
425        }
426        let step = fx / dfx;
427        let x_new = x - step;
428        if !x_new.is_finite() {
429            return Err(failed(
430                OP,
431                format!("iterate became non-finite after the step {step:e} from x = {x:e}"),
432            ));
433        }
434        if step.abs() <= opts.xtol + opts.rtol * x_new.abs() {
435            return Ok(x_new);
436        }
437        x = x_new;
438    }
439    Err(failed(
440        OP,
441        format!(
442            "did not converge within {} iterations; last iterate x = {x}, |f(x)| = {:.3e}",
443            opts.max_iter,
444            f(x).abs()
445        ),
446    ))
447}
448
449// ═══════════════════════════════════════════════════════════════════════════
450// Minimisation
451// ═══════════════════════════════════════════════════════════════════════════
452
453/// Options for [`nelder_mead`], [`minimize_scalar`] and [`golden_section`].
454///
455/// ```
456/// use symplex::optimize::MinimizeOpts;
457///
458/// let opts = MinimizeOpts::default();
459/// assert_eq!(opts.xtol, 1e-8);
460/// assert_eq!(opts.ftol, 1e-12);
461/// assert_eq!(opts.max_iter, 0);       // automatic: 200·n
462/// assert_eq!(opts.initial_step, 0.0); // automatic: 5 % of |x₀ᵢ|, or 0.00025
463/// ```
464#[derive(Clone, Debug, PartialEq)]
465pub struct MinimizeOpts {
466    /// Absolute tolerance on the location of the minimum (default `1e-8`).
467    ///
468    /// Nelder–Mead stops when every simplex vertex is within `xtol` of the
469    /// best one (in the max norm) *and* the `ftol` criterion holds.  The
470    /// bracketed scalar minimisers stop when the bracket has shrunk to
471    /// `xtol + √ε·|x|`; asking for more than `√ε·|x|` is pointless because
472    /// the objective is flat to rounding on that scale.
473    pub xtol: f64,
474    /// Absolute tolerance on the objective value (default `1e-12`).
475    ///
476    /// Nelder–Mead requires every vertex value to be within `ftol` of the
477    /// best one.  Not used by the bracketed scalar minimisers.
478    pub ftol: f64,
479    /// Maximum number of iterations.  `0` (the default) selects `200·n`,
480    /// where `n` is the number of variables.
481    ///
482    /// Reaching the budget is not an error for [`nelder_mead`] — the result
483    /// carries [`MinimizeResult::converged`]` == false` — but it is for the
484    /// scalar minimisers, which have no way to report partial success.
485    pub max_iter: usize,
486    /// Nelder–Mead initial simplex edge length.  `0.0` (the default) uses
487    /// the SciPy convention: vertex `i` perturbs coordinate `i` of `x0` by
488    /// 5 % of its value, or by `0.00025` when that coordinate is zero.  A
489    /// positive value is used as an absolute perturbation for every
490    /// coordinate.  Ignored by the scalar minimisers.
491    pub initial_step: f64,
492}
493
494impl Default for MinimizeOpts {
495    fn default() -> Self {
496        Self {
497            xtol: 1e-8,
498            ftol: 1e-12,
499            max_iter: 0,
500            initial_step: 0.0,
501        }
502    }
503}
504
505impl MinimizeOpts {
506    fn effective_max_iter(&self, n: usize) -> usize {
507        if self.max_iter == 0 {
508            200usize.saturating_mul(n)
509        } else {
510            self.max_iter
511        }
512    }
513}
514
515fn check_minimize_opts(op: &'static str, opts: &MinimizeOpts) -> Result<(), SymplexError> {
516    let bad = |t: f64| t < 0.0 || !t.is_finite();
517    if bad(opts.xtol) || bad(opts.ftol) || bad(opts.initial_step) {
518        return Err(invalid(
519            op,
520            format!(
521                "xtol, ftol and initial_step must be finite and non-negative, got {}, {}, {}",
522                opts.xtol, opts.ftol, opts.initial_step
523            ),
524        ));
525    }
526    Ok(())
527}
528
529/// Outcome of a multivariate minimisation ([`nelder_mead`],
530/// [`differential_evolution`] and the `Ex` wrappers).
531#[derive(Clone, Debug, PartialEq)]
532pub struct MinimizeResult {
533    /// Location of the best point found.
534    pub x: Vec<f64>,
535    /// Objective value at [`x`](Self::x).
536    pub fun: f64,
537    /// Iterations performed: Nelder–Mead steps, or generations for
538    /// differential evolution.
539    pub iterations: usize,
540    /// Total number of objective evaluations (including any polishing).
541    pub evaluations: usize,
542    /// `true` if the stopping criterion was met before the iteration budget
543    /// ran out.  When `false`, `x` is still the best point seen.
544    pub converged: bool,
545}
546
547/// Minimise `f` by the Nelder–Mead downhill-simplex method starting from `x0`.
548///
549/// Uses the standard reflect / expand / contract / shrink steps.  For `n ≤ 2`
550/// the classic coefficients `(1, 2, ½, ½)` are used; for `n > 2` the
551/// dimension-adaptive coefficients `(1, 1 + 2/n, ¾ − 1/(2n), 1 − 1/n)`
552/// are used, which markedly improve behaviour in higher dimensions.  The
553/// iteration stops when all vertices are within [`MinimizeOpts::xtol`] of
554/// the best vertex and all objective values within [`MinimizeOpts::ftol`]
555/// of the best value.  `NaN` objective values are treated as `+∞`, so the
556/// simplex simply moves away from regions where `f` is undefined.
557///
558/// Exhausting [`MinimizeOpts::max_iter`] is **not** an error: the best
559/// vertex is returned with [`MinimizeResult::converged`]` == false`.
560///
561/// # Errors
562///
563/// * [`SymplexError::InvalidArgument`] if `x0` is empty or contains a
564///   non-finite entry, if `f(x0)` is not finite, or if the options are
565///   negative.
566/// * [`SymplexError::ComputationFailed`] if `f` returns `−∞` (the objective
567///   is unbounded below).
568///
569/// # Examples
570///
571/// ```
572/// use symplex::optimize::{nelder_mead, MinimizeOpts};
573///
574/// // Rosenbrock's banana function; minimum f = 0 at (1, 1).
575/// let rosen = |p: &[f64]| (1.0 - p[0]).powi(2) + 100.0 * (p[1] - p[0] * p[0]).powi(2);
576/// let opts = MinimizeOpts { max_iter: 2000, ..MinimizeOpts::default() };
577/// let r = nelder_mead(rosen, &[-1.2, 1.0], &opts).unwrap();
578/// assert!(r.converged);
579/// assert!((r.x[0] - 1.0).abs() < 1e-4 && (r.x[1] - 1.0).abs() < 1e-4);
580/// assert!(r.fun < 1e-8);
581/// ```
582pub fn nelder_mead(
583    mut f: impl FnMut(&[f64]) -> f64,
584    x0: &[f64],
585    opts: &MinimizeOpts,
586) -> Result<MinimizeResult, SymplexError> {
587    const OP: &str = "nelder_mead";
588    let n = x0.len();
589    if n == 0 {
590        return Err(invalid(OP, "initial point must not be empty".into()));
591    }
592    if x0.iter().any(|v| !v.is_finite()) {
593        return Err(invalid(
594            OP,
595            format!("initial point must be finite, got {x0:?}"),
596        ));
597    }
598    check_minimize_opts(OP, opts)?;
599    let max_iter = opts.effective_max_iter(n);
600
601    let mut evaluations = 0usize;
602    let mut eval = |x: &[f64]| -> f64 {
603        evaluations += 1;
604        nan_to_inf(f(x))
605    };
606
607    let f0 = eval(x0);
608    if !f0.is_finite() {
609        return Err(invalid(
610            OP,
611            format!("f(x0) = {f0} is not finite at x0 = {x0:?}"),
612        ));
613    }
614
615    // Initial simplex: x0 plus one perturbed vertex per coordinate.
616    let mut vertices: Vec<(Vec<f64>, f64)> = Vec::with_capacity(n + 1);
617    vertices.push((x0.to_vec(), f0));
618    for i in 0..n {
619        let mut p = x0.to_vec();
620        p[i] = if opts.initial_step > 0.0 {
621            p[i] + opts.initial_step
622        } else if p[i] != 0.0 {
623            p[i] * 1.05
624        } else {
625            0.000_25
626        };
627        let fp = eval(&p);
628        vertices.push((p, fp));
629    }
630
631    let nf = n as f64;
632    let (rho, chi, psi, sigma) = if n > 2 {
633        (1.0, 1.0 + 2.0 / nf, 0.75 - 0.5 / nf, 1.0 - 1.0 / nf)
634    } else {
635        (1.0, 2.0, 0.5, 0.5)
636    };
637
638    // `(1 + t)·xbar − t·xw`: the point on the line through the centroid and
639    // the worst vertex, parameterised so that t = rho is the reflection.
640    let along = |xbar: &[f64], xw: &[f64], t: f64| -> Vec<f64> {
641        xbar.iter()
642            .zip(xw)
643            .map(|(c, w)| (1.0 + t) * c - t * w)
644            .collect()
645    };
646
647    let mut iterations = 0usize;
648    let mut converged = false;
649    loop {
650        vertices.sort_by(|a, b| a.1.total_cmp(&b.1));
651        if vertices[0].1 == f64::NEG_INFINITY {
652            return Err(failed(
653                OP,
654                format!(
655                    "objective is unbounded below: f = -inf at {:?}",
656                    vertices[0].0
657                ),
658            ));
659        }
660        if simplex_converged(&vertices, opts.xtol, opts.ftol) {
661            converged = true;
662            break;
663        }
664        if iterations >= max_iter {
665            break;
666        }
667        iterations += 1;
668
669        let mut xbar = vec![0.0; n];
670        for (v, _) in &vertices[..n] {
671            for (c, xi) in xbar.iter_mut().zip(v) {
672                *c += xi;
673            }
674        }
675        for c in &mut xbar {
676            *c /= nf;
677        }
678        let xw = vertices[n].0.clone();
679        let fw = vertices[n].1;
680        let f_best = vertices[0].1;
681        let f_second_worst = vertices[n - 1].1;
682
683        let xr = along(&xbar, &xw, rho);
684        let fr = eval(&xr);
685        if fr < f_best {
686            let xe = along(&xbar, &xw, rho * chi);
687            let fe = eval(&xe);
688            vertices[n] = if fe < fr { (xe, fe) } else { (xr, fr) };
689        } else if fr < f_second_worst {
690            vertices[n] = (xr, fr);
691        } else {
692            let mut shrink = false;
693            if fr < fw {
694                // Outside contraction.
695                let xc = along(&xbar, &xw, psi * rho);
696                let fc = eval(&xc);
697                if fc <= fr {
698                    vertices[n] = (xc, fc);
699                } else {
700                    shrink = true;
701                }
702            } else {
703                // Inside contraction.
704                let xcc = along(&xbar, &xw, -psi);
705                let fcc = eval(&xcc);
706                if fcc < fw {
707                    vertices[n] = (xcc, fcc);
708                } else {
709                    shrink = true;
710                }
711            }
712            if shrink {
713                let best = vertices[0].0.clone();
714                for (v, fv) in vertices.iter_mut().skip(1) {
715                    for (xj, bj) in v.iter_mut().zip(&best) {
716                        *xj = bj + sigma * (*xj - bj);
717                    }
718                    *fv = eval(v);
719                }
720            }
721        }
722    }
723
724    let (x, fun) = vertices.swap_remove(0);
725    Ok(MinimizeResult {
726        x,
727        fun,
728        iterations,
729        evaluations,
730        converged,
731    })
732}
733
734/// Nelder–Mead stopping test on a simplex sorted by objective value.
735fn simplex_converged(vertices: &[(Vec<f64>, f64)], xtol: f64, ftol: f64) -> bool {
736    let Some(((x0, f0), rest)) = vertices.split_first() else {
737        return true;
738    };
739    let dx = rest
740        .iter()
741        .flat_map(|(x, _)| x.iter().zip(x0).map(|(a, b)| (a - b).abs()))
742        .fold(0.0_f64, f64::max);
743    let df = rest
744        .iter()
745        .map(|(_, fv)| (fv - f0).abs())
746        .fold(0.0_f64, f64::max);
747    dx <= xtol && df <= ftol
748}
749
750/// Evaluate `f(x)` for a scalar minimiser, rejecting non-finite values.
751fn eval_finite(op: &'static str, f: &impl Fn(f64) -> f64, x: f64) -> Result<f64, SymplexError> {
752    let v = f(x);
753    if v.is_finite() {
754        Ok(v)
755    } else {
756        Err(failed(op, format!("f({x}) = {v} is not finite")))
757    }
758}
759
760/// Minimise a scalar function on `[a, b]` by Brent's method.
761///
762/// Combines golden-section steps with successive parabolic interpolation
763/// (Brent's `localmin`), giving superlinear convergence on smooth functions
764/// and golden-section behaviour otherwise.  Returns `(x_min, f_min)`.  On a
765/// bracket containing several local minima the method converges to one of
766/// them; which one depends on the bracket.  A reversed interval is
767/// accepted.
768///
769/// # Errors
770///
771/// * [`SymplexError::InvalidArgument`] if an endpoint is not finite, the
772///   interval has zero width, or the options are negative.
773/// * [`SymplexError::ComputationFailed`] if `f` returns a non-finite value
774///   or the tolerance is not met within the iteration budget (default
775///   `200`).
776///
777/// # Examples
778///
779/// ```
780/// use symplex::optimize::{minimize_scalar, MinimizeOpts};
781///
782/// let (x, fx) = minimize_scalar(|x| (x - 1.0).powi(2) + 3.0, -5.0, 5.0, &MinimizeOpts::default()).unwrap();
783/// assert!((x - 1.0).abs() < 1e-6);
784/// assert!((fx - 3.0).abs() < 1e-12);
785/// ```
786pub fn minimize_scalar(
787    f: impl Fn(f64) -> f64,
788    a: f64,
789    b: f64,
790    opts: &MinimizeOpts,
791) -> Result<(f64, f64), SymplexError> {
792    const OP: &str = "minimize_scalar";
793    let (mut a, mut b) = check_interval(OP, a, b)?;
794    check_minimize_opts(OP, opts)?;
795    let max_iter = opts.effective_max_iter(1);
796    let cgold = 0.5 * (3.0 - 5.0_f64.sqrt());
797    let sqrt_eps = f64::EPSILON.sqrt();
798
799    // x: best point; w: second best; v: previous w.
800    let mut x = a + cgold * (b - a);
801    let mut w = x;
802    let mut v = x;
803    let mut fx = eval_finite(OP, &f, x)?;
804    let mut fw = fx;
805    let mut fv = fx;
806    let mut d = 0.0_f64; // last step
807    let mut e = 0.0_f64; // step before last
808
809    for _ in 0..max_iter {
810        let xm = 0.5 * (a + b);
811        let tol1 = sqrt_eps * x.abs() + opts.xtol / 3.0;
812        let tol2 = 2.0 * tol1;
813        if (x - xm).abs() <= tol2 - 0.5 * (b - a) {
814            return Ok((x, fx));
815        }
816        let golden = if e.abs() > tol1 {
817            // Parabola through (x, fx), (v, fv), (w, fw).
818            let r = (x - w) * (fx - fv);
819            let mut q = (x - v) * (fx - fw);
820            let mut p = (x - v) * q - (x - w) * r;
821            q = 2.0 * (q - r);
822            if q > 0.0 {
823                p = -p;
824            }
825            q = q.abs();
826            let e_prev = e;
827            e = d;
828            if p.abs() >= (0.5 * q * e_prev).abs() || p <= q * (a - x) || p >= q * (b - x) {
829                true
830            } else {
831                d = p / q;
832                let u = x + d;
833                if u - a < tol2 || b - u < tol2 {
834                    d = tol1.copysign(xm - x);
835                }
836                false
837            }
838        } else {
839            true
840        };
841        if golden {
842            e = if x >= xm { a - x } else { b - x };
843            d = cgold * e;
844        }
845        let u = if d.abs() >= tol1 {
846            x + d
847        } else {
848            x + tol1.copysign(d)
849        };
850        let fu = eval_finite(OP, &f, u)?;
851        if fu <= fx {
852            if u >= x {
853                a = x;
854            } else {
855                b = x;
856            }
857            v = w;
858            fv = fw;
859            w = x;
860            fw = fx;
861            x = u;
862            fx = fu;
863        } else {
864            if u < x {
865                a = u;
866            } else {
867                b = u;
868            }
869            if fu <= fw || w == x {
870                v = w;
871                fv = fw;
872                w = u;
873                fw = fu;
874            } else if fu <= fv || v == x || v == w {
875                v = u;
876                fv = fu;
877            }
878        }
879    }
880    Err(failed(
881        OP,
882        format!("did not converge within {max_iter} iterations; bracket [{a}, {b}], best x = {x}"),
883    ))
884}
885
886/// Minimise a scalar function on `[a, b]` by golden-section search.
887///
888/// Shrinks the bracket by the golden ratio each iteration using only
889/// function comparisons; linear convergence, but immune to the parabolic
890/// mis-steps of [`minimize_scalar`] on badly behaved functions.  Returns
891/// `(x_min, f_min)`.  Same argument rules and errors as
892/// [`minimize_scalar`].
893///
894/// # Examples
895///
896/// ```
897/// use symplex::optimize::{golden_section, MinimizeOpts};
898///
899/// // x·ln x has its minimum at x = 1/e.
900/// let (x, fx) = golden_section(|x| x * x.ln(), 0.1, 2.0, &MinimizeOpts::default()).unwrap();
901/// assert!((x - (-1.0f64).exp()).abs() < 1e-6);
902/// assert!((fx + (-1.0f64).exp()).abs() < 1e-12);
903/// ```
904pub fn golden_section(
905    f: impl Fn(f64) -> f64,
906    a: f64,
907    b: f64,
908    opts: &MinimizeOpts,
909) -> Result<(f64, f64), SymplexError> {
910    const OP: &str = "golden_section";
911    let (mut a, mut b) = check_interval(OP, a, b)?;
912    check_minimize_opts(OP, opts)?;
913    let max_iter = opts.effective_max_iter(1);
914    let inv_phi = 0.5 * (5.0_f64.sqrt() - 1.0);
915    let sqrt_eps = f64::EPSILON.sqrt();
916
917    let mut x1 = b - inv_phi * (b - a);
918    let mut x2 = a + inv_phi * (b - a);
919    let mut f1 = eval_finite(OP, &f, x1)?;
920    let mut f2 = eval_finite(OP, &f, x2)?;
921    for _ in 0..max_iter {
922        let mid = 0.5 * (a + b);
923        if (b - a).abs() <= opts.xtol + sqrt_eps * mid.abs() {
924            return Ok(if f1 <= f2 { (x1, f1) } else { (x2, f2) });
925        }
926        if f1 < f2 {
927            b = x2;
928            x2 = x1;
929            f2 = f1;
930            x1 = b - inv_phi * (b - a);
931            f1 = eval_finite(OP, &f, x1)?;
932        } else {
933            a = x1;
934            x1 = x2;
935            f1 = f2;
936            x2 = a + inv_phi * (b - a);
937            f2 = eval_finite(OP, &f, x2)?;
938        }
939    }
940    Err(failed(
941        OP,
942        format!("did not converge within {max_iter} iterations; bracket [{a}, {b}]"),
943    ))
944}
945
946// ═══════════════════════════════════════════════════════════════════════════
947// Differential evolution
948// ═══════════════════════════════════════════════════════════════════════════
949
950/// SplitMix64: a tiny, fast, well-distributed 64-bit generator.  Used so
951/// that [`differential_evolution`] is reproducible from a `u64` seed
952/// without pulling in a dependency.
953struct SplitMix64(u64);
954
955impl SplitMix64 {
956    fn new(seed: u64) -> Self {
957        Self(seed)
958    }
959
960    fn next_u64(&mut self) -> u64 {
961        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
962        let mut z = self.0;
963        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
964        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
965        z ^ (z >> 31)
966    }
967
968    /// Uniform in `[0, 1)` with 53 random bits.
969    fn next_f64(&mut self) -> f64 {
970        const SCALE: f64 = 1.0 / (1u64 << 53) as f64;
971        (self.next_u64() >> 11) as f64 * SCALE
972    }
973
974    /// Uniform in `0..n` (`0` when `n == 0`).
975    fn below(&mut self, n: usize) -> usize {
976        // The modulo bias is < 2⁻⁴⁰ for any realistic population size.
977        (self.next_u64() % (n as u64).max(1)) as usize
978    }
979
980    /// Uniform index in `0..n` that is not in `excluded`.
981    ///
982    /// `excluded` must hold distinct values `< n` and is sorted in place;
983    /// the caller guarantees `excluded.len() < n`.
984    fn below_excluding(&mut self, n: usize, excluded: &mut [usize]) -> usize {
985        excluded.sort_unstable();
986        let mut r = self.below(n.saturating_sub(excluded.len()));
987        for &e in excluded.iter() {
988            if r >= e {
989                r += 1;
990            }
991        }
992        r
993    }
994
995    fn shuffle<T>(&mut self, items: &mut [T]) {
996        for i in (1..items.len()).rev() {
997            let j = self.below(i + 1);
998            items.swap(i, j);
999        }
1000    }
1001}
1002
1003/// Options for [`differential_evolution`].
1004///
1005/// ```
1006/// use symplex::optimize::DeOpts;
1007///
1008/// let opts = DeOpts::default();
1009/// assert_eq!(opts.population, 0); // automatic: max(15·n, 8)
1010/// assert_eq!(opts.max_generations, 300);
1011/// assert_eq!(opts.crossover, 0.7);
1012/// assert_eq!(opts.differential_weight, 0.8);
1013/// assert_eq!(opts.tol, 1e-8);
1014/// assert_eq!(opts.seed, 0);
1015/// ```
1016#[derive(Clone, Debug, PartialEq)]
1017pub struct DeOpts {
1018    /// Population size.  `0` (the default) selects `max(15·n, 8)` for `n`
1019    /// variables.  Explicit values must be at least `4`.
1020    pub population: usize,
1021    /// Maximum number of generations (default `300`).
1022    pub max_generations: usize,
1023    /// Crossover probability `CR ∈ [0, 1]` (default `0.7`): the chance that
1024    /// each coordinate of a trial vector is taken from the mutant rather
1025    /// than the parent.  One coordinate is always taken from the mutant.
1026    pub crossover: f64,
1027    /// Differential weight `F > 0` (default `0.8`) scaling the difference
1028    /// vector in `x_{r1} + F·(x_{r2} − x_{r3})`.
1029    pub differential_weight: f64,
1030    /// Convergence tolerance (default `1e-8`).  The run stops once the
1031    /// standard deviation of the population's objective values is at most
1032    /// `tol·(1 + |mean|)`.
1033    pub tol: f64,
1034    /// Seed of the internal SplitMix64 generator (default `0`).  Identical
1035    /// seeds and inputs give bit-identical results.
1036    pub seed: u64,
1037}
1038
1039impl Default for DeOpts {
1040    fn default() -> Self {
1041        Self {
1042            population: 0,
1043            max_generations: 300,
1044            crossover: 0.7,
1045            differential_weight: 0.8,
1046            tol: 1e-8,
1047            seed: 0,
1048        }
1049    }
1050}
1051
1052/// Population-convergence test for differential evolution.
1053fn population_converged(energies: &[f64], tol: f64) -> bool {
1054    let n = energies.len() as f64;
1055    if n == 0.0 {
1056        return true;
1057    }
1058    let mean = energies.iter().sum::<f64>() / n;
1059    let var = energies
1060        .iter()
1061        .map(|e| (e - mean) * (e - mean))
1062        .sum::<f64>()
1063        / n;
1064    var.sqrt() <= tol * (1.0 + mean.abs())
1065}
1066
1067/// Global minimisation of `f` over the box `bounds` by differential
1068/// evolution (strategy `DE/rand/1/bin`), followed by a Nelder–Mead polish
1069/// of the best member.
1070///
1071/// The population is initialised by Latin-hypercube sampling; each
1072/// generation builds one trial vector per member from three other distinct
1073/// members (`x_{r1} + F·(x_{r2} − x_{r3})`, binomial crossover), clips it
1074/// to the box, and replaces the member when the trial is no worse.  Every
1075/// point at which `f` is evaluated — including during the polish — lies
1076/// inside `bounds`.  The run is deterministic for a given
1077/// [`DeOpts::seed`].
1078///
1079/// [`MinimizeResult::iterations`] is the number of generations,
1080/// [`MinimizeResult::evaluations`] counts all objective calls including the
1081/// polish, and [`MinimizeResult::converged`] reports whether the
1082/// population-spread criterion ([`DeOpts::tol`]) was met before
1083/// [`DeOpts::max_generations`] ran out.
1084///
1085/// # Errors
1086///
1087/// * [`SymplexError::InvalidArgument`] if `bounds` is empty, a bound is not
1088///   finite or reversed, the population is smaller than 4, `crossover` is
1089///   outside `[0, 1]`, or `differential_weight`/`tol` are not positive and
1090///   finite.
1091/// * [`SymplexError::ComputationFailed`] if `f` has no finite value
1092///   anywhere the search looked.
1093///
1094/// # Examples
1095///
1096/// ```
1097/// use symplex::optimize::{differential_evolution, DeOpts};
1098///
1099/// // Rastrigin's function: many local minima, global minimum 0 at the origin.
1100/// let rastrigin = |p: &[f64]| {
1101///     10.0 * p.len() as f64
1102///         + p.iter()
1103///             .map(|x| x * x - 10.0 * (2.0 * std::f64::consts::PI * x).cos())
1104///             .sum::<f64>()
1105/// };
1106/// let bounds = [(-5.12, 5.12), (-5.12, 5.12)];
1107/// let r = differential_evolution(rastrigin, &bounds, &DeOpts::default()).unwrap();
1108/// assert!(r.fun < 1e-6, "f = {}", r.fun);
1109/// assert!(r.x.iter().all(|x| x.abs() < 1e-3));
1110/// ```
1111pub fn differential_evolution(
1112    mut f: impl FnMut(&[f64]) -> f64,
1113    bounds: &[(f64, f64)],
1114    opts: &DeOpts,
1115) -> Result<MinimizeResult, SymplexError> {
1116    const OP: &str = "differential_evolution";
1117    let n = bounds.len();
1118    if n == 0 {
1119        return Err(invalid(OP, "bounds must not be empty".into()));
1120    }
1121    for &(lo, hi) in bounds {
1122        if !lo.is_finite() || !hi.is_finite() || lo > hi {
1123            return Err(invalid(
1124                OP,
1125                format!(
1126                    "each bound must be a finite (lo, hi) pair with lo <= hi, got ({lo}, {hi})"
1127                ),
1128            ));
1129        }
1130    }
1131    if !(0.0..=1.0).contains(&opts.crossover) {
1132        return Err(invalid(
1133            OP,
1134            format!("crossover must lie in [0, 1], got {}", opts.crossover),
1135        ));
1136    }
1137    if !opts.differential_weight.is_finite() || opts.differential_weight <= 0.0 {
1138        return Err(invalid(
1139            OP,
1140            format!(
1141                "differential_weight must be positive and finite, got {}",
1142                opts.differential_weight
1143            ),
1144        ));
1145    }
1146    if !opts.tol.is_finite() || opts.tol < 0.0 {
1147        return Err(invalid(
1148            OP,
1149            format!("tol must be finite and non-negative, got {}", opts.tol),
1150        ));
1151    }
1152    let np = if opts.population == 0 {
1153        (15 * n).max(8)
1154    } else {
1155        opts.population
1156    };
1157    if np < 4 {
1158        return Err(invalid(
1159            OP,
1160            format!("population must be at least 4, got {np}"),
1161        ));
1162    }
1163
1164    let mut rng = SplitMix64::new(opts.seed);
1165    let mut evaluations = 0usize;
1166    let mut eval = |x: &[f64]| -> f64 {
1167        evaluations += 1;
1168        nan_to_inf(f(x))
1169    };
1170
1171    // Latin-hypercube initialisation: every coordinate is stratified into
1172    // `np` equal slices, each used exactly once.
1173    let mut pop = vec![vec![0.0; n]; np];
1174    let mut perm: Vec<usize> = (0..np).collect();
1175    for (j, &(lo, hi)) in bounds.iter().enumerate() {
1176        rng.shuffle(&mut perm);
1177        for (member, &slice) in pop.iter_mut().zip(&perm) {
1178            let u = (slice as f64 + rng.next_f64()) / np as f64;
1179            member[j] = lo + u * (hi - lo);
1180        }
1181    }
1182    let mut energies: Vec<f64> = pop.iter().map(|m| eval(m)).collect();
1183    let mut best = argmin(&energies);
1184
1185    let mut generations = 0usize;
1186    let mut converged = false;
1187    let mut trial = vec![0.0; n];
1188    loop {
1189        if population_converged(&energies, opts.tol) {
1190            converged = true;
1191            break;
1192        }
1193        if generations >= opts.max_generations {
1194            break;
1195        }
1196        generations += 1;
1197        for i in 0..np {
1198            let r1 = rng.below_excluding(np, &mut [i]);
1199            let r2 = rng.below_excluding(np, &mut [i, r1]);
1200            let r3 = rng.below_excluding(np, &mut [i, r1, r2]);
1201            let j_rand = rng.below(n);
1202            for (j, &(lo, hi)) in bounds.iter().enumerate() {
1203                let v = if j == j_rand || rng.next_f64() < opts.crossover {
1204                    pop[r1][j] + opts.differential_weight * (pop[r2][j] - pop[r3][j])
1205                } else {
1206                    pop[i][j]
1207                };
1208                // Bounds were validated finite with lo <= hi, so clamp cannot panic.
1209                trial[j] = v.clamp(lo, hi);
1210            }
1211            let ft = eval(&trial);
1212            if ft <= energies[i] {
1213                pop[i].copy_from_slice(&trial);
1214                energies[i] = ft;
1215                if ft < energies[best] {
1216                    best = i;
1217                }
1218            }
1219        }
1220    }
1221    let de_evaluations = evaluations;
1222
1223    let f_best = energies[best];
1224    if !f_best.is_finite() {
1225        return Err(failed(
1226            OP,
1227            format!("objective has no finite value in the box after {generations} generations"),
1228        ));
1229    }
1230
1231    // Local polish, evaluating only inside the box.
1232    let mut clipped = vec![0.0; n];
1233    let polish = nelder_mead(
1234        |x: &[f64]| {
1235            for ((c, &xi), &(lo, hi)) in clipped.iter_mut().zip(x).zip(bounds) {
1236                *c = xi.clamp(lo, hi);
1237            }
1238            f(&clipped)
1239        },
1240        &pop[best],
1241        &MinimizeOpts::default(),
1242    )
1243    .map_err(|e| failed(OP, format!("Nelder–Mead polish failed: {e}")))?;
1244
1245    let (x, fun) = if polish.fun < f_best {
1246        let x = polish
1247            .x
1248            .iter()
1249            .zip(bounds)
1250            .map(|(&xi, &(lo, hi))| xi.clamp(lo, hi))
1251            .collect();
1252        (x, polish.fun)
1253    } else {
1254        (pop.swap_remove(best), f_best)
1255    };
1256    Ok(MinimizeResult {
1257        x,
1258        fun,
1259        iterations: generations,
1260        evaluations: de_evaluations + polish.evaluations,
1261        converged,
1262    })
1263}
1264
1265/// Index of the smallest value (`0` for an empty slice).
1266fn argmin(values: &[f64]) -> usize {
1267    values.iter().enumerate().fold(
1268        0usize,
1269        |best, (i, &v)| {
1270            if v < values[best] { i } else { best }
1271        },
1272    )
1273}
1274
1275// ═══════════════════════════════════════════════════════════════════════════
1276// Least-squares fitting
1277// ═══════════════════════════════════════════════════════════════════════════
1278
1279/// Least-squares polynomial fit of degree `degree` to the samples
1280/// `(xs[i], ys[i])`.
1281///
1282/// Returns the coefficients in **ascending** degree, `[c₀, c₁, …, c_d]`,
1283/// so that `ys[i] ≈ c₀ + c₁·xs[i] + … + c_d·xs[i]^d` (evaluate with
1284/// [`eval_poly`]).  The Vandermonde matrix is column-scaled and factored
1285/// by Householder QR, which is backward stable; the normal equations are
1286/// never formed.  With `degree + 1 == xs.len()` the fit interpolates.
1287///
1288/// # Errors
1289///
1290/// [`SymplexError::InvalidArgument`] if the slices differ in length,
1291/// `degree >= xs.len()`, or any sample is not finite;
1292/// [`SymplexError::ComputationFailed`] if the Vandermonde matrix is
1293/// numerically rank deficient (fewer than `degree + 1` distinct
1294/// abscissae).
1295///
1296/// # Examples
1297///
1298/// ```
1299/// use symplex::optimize::{eval_poly, poly_fit};
1300///
1301/// let xs: Vec<f64> = (0..6).map(f64::from).collect();
1302/// let ys: Vec<f64> = xs.iter().map(|x| 1.0 + 2.0 * x + 3.0 * x * x).collect();
1303/// let c = poly_fit(&xs, &ys, 2).unwrap();
1304/// assert!((c[0] - 1.0).abs() < 1e-9 && (c[1] - 2.0).abs() < 1e-9 && (c[2] - 3.0).abs() < 1e-9);
1305/// assert!((eval_poly(&c, 10.0) - 321.0).abs() < 1e-7);
1306///
1307/// // Not enough points for the requested degree.
1308/// assert!(poly_fit(&[0.0, 1.0], &[0.0, 1.0], 2).is_err());
1309/// ```
1310pub fn poly_fit(xs: &[f64], ys: &[f64], degree: usize) -> Result<Vec<f64>, SymplexError> {
1311    const OP: &str = "poly_fit";
1312    let m = xs.len();
1313    if m != ys.len() {
1314        return Err(invalid(
1315            OP,
1316            format!(
1317                "xs and ys must have the same length, got {m} and {}",
1318                ys.len()
1319            ),
1320        ));
1321    }
1322    if degree >= m {
1323        return Err(invalid(
1324            OP,
1325            format!(
1326                "degree {degree} needs at least {} points, got {m}",
1327                degree + 1
1328            ),
1329        ));
1330    }
1331    if xs.iter().chain(ys).any(|v| !v.is_finite()) {
1332        return Err(invalid(OP, "all samples must be finite".into()));
1333    }
1334    let ncols = degree + 1;
1335    let mut a: Vec<Vec<f64>> = xs
1336        .iter()
1337        .map(|&x| {
1338            let mut p = 1.0;
1339            (0..ncols)
1340                .map(|_| {
1341                    let v = p;
1342                    p *= x;
1343                    v
1344                })
1345                .collect()
1346        })
1347        .collect();
1348    // Equilibrate the columns: brings the condition number within a
1349    // modest factor of the best diagonal scaling.
1350    let mut scale = vec![1.0; ncols];
1351    for (j, s) in scale.iter_mut().enumerate() {
1352        let norm = a.iter().map(|row| row[j] * row[j]).sum::<f64>().sqrt();
1353        if norm > 0.0 && norm.is_finite() {
1354            *s = norm;
1355            for row in &mut a {
1356                row[j] /= norm;
1357            }
1358        }
1359    }
1360    let c = lstsq_householder(a, ys.to_vec(), ncols).ok_or_else(|| {
1361        failed(
1362            OP,
1363            format!("Vandermonde matrix is rank deficient: fewer than {ncols} distinct abscissae"),
1364        )
1365    })?;
1366    Ok(c.iter().zip(&scale).map(|(c, s)| c / s).collect())
1367}
1368
1369/// Least-squares solution of the overdetermined system `a·x = b`
1370/// (`a` is `m × n` with `m ≥ n`) via Householder QR.  `None` if `a` is
1371/// numerically rank deficient.
1372fn lstsq_householder(mut a: Vec<Vec<f64>>, mut b: Vec<f64>, n: usize) -> Option<Vec<f64>> {
1373    let m = a.len();
1374    if m < n || b.len() != m {
1375        return None;
1376    }
1377    for k in 0..n {
1378        let norm = (k..m).map(|i| a[i][k] * a[i][k]).sum::<f64>().sqrt();
1379        if norm == 0.0 || !norm.is_finite() {
1380            return None;
1381        }
1382        // Householder vector v = x − α·e₁ with α chosen to avoid cancellation.
1383        let alpha = if a[k][k] > 0.0 { -norm } else { norm };
1384        let mut v: Vec<f64> = (k..m).map(|i| a[i][k]).collect();
1385        v[0] -= alpha;
1386        let vnorm2: f64 = v.iter().map(|x| x * x).sum();
1387        if vnorm2 == 0.0 {
1388            continue;
1389        }
1390        // Apply H = I − 2vvᵀ/‖v‖² to the trailing block of `a` and to `b`:
1391        // A ← A − (2/‖v‖²)·v·(vᵀA).
1392        let scale = 2.0 / vnorm2;
1393        let w: Vec<f64> = (k..n)
1394            .map(|j| v.iter().zip(k..m).map(|(vi, i)| vi * a[i][j]).sum::<f64>())
1395            .collect();
1396        for (vi, i) in v.iter().zip(k..m) {
1397            for (wj, entry) in w.iter().zip(a[i][k..].iter_mut()) {
1398                *entry -= scale * vi * wj;
1399            }
1400        }
1401        let s: f64 = v.iter().zip(k..m).map(|(vi, i)| vi * b[i]).sum();
1402        let factor = scale * s;
1403        for (vi, i) in v.iter().zip(k..m) {
1404            b[i] -= factor * vi;
1405        }
1406    }
1407    // Back-substitution on the leading n × n block (R).
1408    let r_max = (0..n).map(|k| a[k][k].abs()).fold(0.0_f64, f64::max);
1409    let threshold = r_max * f64::EPSILON * m as f64;
1410    let mut x = vec![0.0; n];
1411    for r in (0..n).rev() {
1412        let diag = a[r][r];
1413        if !diag.is_finite() || diag.abs() <= threshold {
1414            return None;
1415        }
1416        let s = b[r] - ((r + 1)..n).map(|c| a[r][c] * x[c]).sum::<f64>();
1417        x[r] = s / diag;
1418    }
1419    Some(x)
1420}
1421
1422/// Exact least-squares polynomial fit over ℚ.
1423///
1424/// Solves the normal equations `AᵀA·c = Aᵀy` for the Vandermonde matrix
1425/// `A` with exact rational Gaussian elimination, so the returned
1426/// coefficients (in **ascending** degree) are the exact least-squares
1427/// solution — for consistent data, the exact interpolating polynomial.
1428///
1429/// # Errors
1430///
1431/// [`SymplexError::InvalidArgument`] if `degree >= points.len()`;
1432/// [`SymplexError::ComputationFailed`] if the normal matrix is singular
1433/// (fewer than `degree + 1` distinct abscissae).
1434///
1435/// # Examples
1436///
1437/// ```
1438/// use num_bigint::BigInt;
1439/// use num_rational::Ratio;
1440/// use symplex::optimize::poly_fit_exact;
1441///
1442/// let q = |p: i64, d: i64| Ratio::new(BigInt::from(p), BigInt::from(d));
1443/// // y = x²/3 − x/2 + 1/7 sampled at x = 0, 1, 2, 3, 4 (five points, degree 2).
1444/// let pts = [
1445///     (q(0, 1), q(1, 7)),
1446///     (q(1, 1), q(-1, 42)),
1447///     (q(2, 1), q(10, 21)),
1448///     (q(3, 1), q(23, 14)),
1449///     (q(4, 1), q(73, 21)),
1450/// ];
1451/// let c = poly_fit_exact(&pts, 2).unwrap();
1452/// assert_eq!(c, vec![q(1, 7), q(-1, 2), q(1, 3)]);
1453/// ```
1454pub fn poly_fit_exact(
1455    points: &[(Ratio<BigInt>, Ratio<BigInt>)],
1456    degree: usize,
1457) -> Result<Vec<Ratio<BigInt>>, SymplexError> {
1458    const OP: &str = "poly_fit_exact";
1459    let m = points.len();
1460    if degree >= m {
1461        return Err(invalid(
1462            OP,
1463            format!(
1464                "degree {degree} needs at least {} points, got {m}",
1465                degree + 1
1466            ),
1467        ));
1468    }
1469    let ncols = degree + 1;
1470    // Power sums S_p = Σ xᵖ (p ≤ 2d) and moments T_j = Σ xʲ·y (j ≤ d).
1471    let mut power_sums = vec![Ratio::<BigInt>::zero(); 2 * degree + 1];
1472    let mut moments = vec![Ratio::<BigInt>::zero(); ncols];
1473    for (x, y) in points {
1474        let mut pow = Ratio::<BigInt>::one();
1475        for (p, s) in power_sums.iter_mut().enumerate() {
1476            *s += &pow;
1477            if let Some(t) = moments.get_mut(p) {
1478                *t += &pow * y;
1479            }
1480            if p + 1 < 2 * degree + 1 {
1481                pow *= x;
1482            }
1483        }
1484    }
1485    let normal: Vec<Vec<Ratio<BigInt>>> = (0..ncols)
1486        .map(|j| (0..ncols).map(|k| power_sums[j + k].clone()).collect())
1487        .collect();
1488    solve_exact(normal, moments).ok_or_else(|| {
1489        failed(
1490            OP,
1491            format!("normal equations are singular: fewer than {ncols} distinct abscissae"),
1492        )
1493    })
1494}
1495
1496/// Exact Gaussian elimination for the square system `a·x = b` over ℚ.
1497/// `None` if the matrix is singular.
1498fn solve_exact(
1499    mut a: Vec<Vec<Ratio<BigInt>>>,
1500    mut b: Vec<Ratio<BigInt>>,
1501) -> Option<Vec<Ratio<BigInt>>> {
1502    let n = b.len();
1503    if a.len() != n || a.iter().any(|row| row.len() != n) {
1504        return None;
1505    }
1506    for col in 0..n {
1507        let pivot = (col..n).find(|&r| !a[r][col].is_zero())?;
1508        a.swap(col, pivot);
1509        b.swap(col, pivot);
1510        let pivot_row = a[col].clone();
1511        let pivot_b = b[col].clone();
1512        for r in (col + 1)..n {
1513            if a[r][col].is_zero() {
1514                continue;
1515            }
1516            let factor = &a[r][col] / &pivot_row[col];
1517            for (entry, p) in a[r].iter_mut().zip(&pivot_row).skip(col) {
1518                *entry -= &factor * p;
1519            }
1520            b[r] -= &factor * &pivot_b;
1521        }
1522    }
1523    let mut x = vec![Ratio::<BigInt>::zero(); n];
1524    for r in (0..n).rev() {
1525        let mut s = b[r].clone();
1526        for c in (r + 1)..n {
1527            s -= &a[r][c] * &x[c];
1528        }
1529        x[r] = s / &a[r][r];
1530    }
1531    Some(x)
1532}
1533
1534/// Least-squares straight line `y ≈ slope·x + intercept`.
1535///
1536/// Returns `(slope, intercept)`.  Equivalent to [`poly_fit`] with
1537/// `degree = 1`; needs at least two samples with distinct abscissae.
1538///
1539/// # Examples
1540///
1541/// ```
1542/// use symplex::optimize::linear_fit;
1543///
1544/// let xs = [0.0, 1.0, 2.0, 3.0];
1545/// let ys = [1.0, 4.0, 7.0, 10.0]; // y = 3x + 1
1546/// let (slope, intercept) = linear_fit(&xs, &ys).unwrap();
1547/// assert!((slope - 3.0).abs() < 1e-12 && (intercept - 1.0).abs() < 1e-12);
1548/// ```
1549pub fn linear_fit(xs: &[f64], ys: &[f64]) -> Result<(f64, f64), SymplexError> {
1550    let c = poly_fit(xs, ys, 1)?;
1551    match c.as_slice() {
1552        [intercept, slope] => Ok((*slope, *intercept)),
1553        _ => Err(failed(
1554            "linear_fit",
1555            format!("expected two coefficients, got {}", c.len()),
1556        )),
1557    }
1558}
1559
1560/// Trapezoidal-rule integral of the samples `ys` at abscissae `xs`.
1561///
1562/// `Σ ½·(xs[i+1] − xs[i])·(ys[i] + ys[i+1])`; fewer than two samples give
1563/// `0`.  The abscissae need not be evenly spaced.
1564///
1565/// # Errors
1566///
1567/// [`SymplexError::InvalidArgument`] if the slices differ in length.
1568///
1569/// # Examples
1570///
1571/// ```
1572/// use symplex::optimize::trapezoid;
1573///
1574/// let xs: Vec<f64> = (0..=1000).map(|i| i as f64 / 1000.0).collect();
1575/// let ys: Vec<f64> = xs.iter().map(|x| x * x).collect();
1576/// assert!((trapezoid(&ys, &xs).unwrap() - 1.0 / 3.0).abs() < 1e-6);
1577/// ```
1578pub fn trapezoid(ys: &[f64], xs: &[f64]) -> Result<f64, SymplexError> {
1579    if ys.len() != xs.len() {
1580        return Err(invalid(
1581            "trapezoid",
1582            format!(
1583                "ys and xs must have the same length, got {} and {}",
1584                ys.len(),
1585                xs.len()
1586            ),
1587        ));
1588    }
1589    Ok(xs
1590        .windows(2)
1591        .zip(ys.windows(2))
1592        .map(|(x, y)| 0.5 * (x[1] - x[0]) * (y[0] + y[1]))
1593        .sum())
1594}
1595
1596/// Evaluate a polynomial given by **ascending** coefficients at `x`
1597/// (Horner's rule).
1598///
1599/// ```
1600/// use symplex::optimize::eval_poly;
1601///
1602/// assert_eq!(eval_poly(&[1.0, 2.0, 3.0], 2.0), 17.0); // 1 + 2·2 + 3·4
1603/// assert_eq!(eval_poly(&[], 5.0), 0.0);
1604/// ```
1605#[must_use]
1606pub fn eval_poly(coeffs_ascending: &[f64], x: f64) -> f64 {
1607    coeffs_ascending
1608        .iter()
1609        .rev()
1610        .fold(0.0, |acc, &c| acc * x + c)
1611}
1612
1613// ═══════════════════════════════════════════════════════════════════════════
1614// Ex conveniences
1615// ═══════════════════════════════════════════════════════════════════════════
1616
1617/// Validate `vars` (same context, all symbols, covering every free symbol
1618/// of `expr`) and compile `expr` as a function of them, in order.
1619fn compile_in(
1620    expr: &Ex,
1621    vars: &[&Ex],
1622    operation: &'static str,
1623) -> Result<CompiledFn, SymplexError> {
1624    if vars.is_empty() {
1625        return Err(invalid(
1626            operation,
1627            "at least one variable is required".into(),
1628        ));
1629    }
1630    let ids: Vec<_> = vars.iter().map(|v| expr.checked_id(*v)).collect();
1631    let non_symbol = {
1632        let inner = expr.inner.read();
1633        ids.iter()
1634            .position(|&id| !matches!(inner.arena.node(id), ExprNode::Symbol(_)))
1635    };
1636    if let Some(i) = non_symbol {
1637        return Err(invalid(
1638            operation,
1639            format!("variables must be symbols, got `{}`", vars[i]),
1640        ));
1641    }
1642    if let Some(extra) = expr.free_symbols().into_iter().find(|s| !vars.contains(&s)) {
1643        return Err(SymplexError::FreeSymbol {
1644            name: format!("{extra}"),
1645        });
1646    }
1647    let names: Vec<String> = vars.iter().map(|v| format!("{v}")).collect();
1648    let name_refs: Vec<&str> = names.iter().map(String::as_str).collect();
1649    expr.compile(&name_refs)
1650}
1651
1652impl Ex {
1653    /// Numerically find a root of this expression in `var` inside the
1654    /// bracket `[a, b]` by [`brent_root`] with default [`RootOpts`].
1655    ///
1656    /// The expression is compiled with [`compile`](Self::compile) first, so
1657    /// evaluation is fast and the usual compile-time checks apply.
1658    ///
1659    /// # Errors
1660    ///
1661    /// * [`SymplexError::InvalidArgument`] if `var` is not a symbol, or the
1662    ///   bracket is invalid (non-finite, or no sign change).
1663    /// * [`SymplexError::FreeSymbol`] if the expression contains a symbol
1664    ///   other than `var`.
1665    /// * [`SymplexError::NotImplemented`] if the expression cannot be
1666    ///   compiled to `f64` arithmetic.
1667    /// * [`SymplexError::ComputationFailed`] if the iteration does not
1668    ///   converge or meets a non-finite value.
1669    ///
1670    /// # Examples
1671    ///
1672    /// ```
1673    /// use symplex::prelude::*;
1674    ///
1675    /// let ctx = Context::new();
1676    /// let x = ctx.symbol("x");
1677    /// let r = (&x.powi(2) - 2).find_root_bracket(&x, 0.0, 2.0).unwrap();
1678    /// assert!((r - 2f64.sqrt()).abs() < 1e-12);
1679    ///
1680    /// // A transcendental equation: cos x = x.
1681    /// let r = (x.cos() - &x).find_root_bracket(&x, 0.0, 1.0).unwrap();
1682    /// assert!((r - 0.739_085_133_215_160_6).abs() < 1e-12);
1683    ///
1684    /// // Another free symbol → FreeSymbol, not a silent NaN.
1685    /// let a = ctx.symbol("a");
1686    /// assert!(matches!(
1687    ///     (&x.powi(2) - &a).find_root_bracket(&x, 0.0, 2.0),
1688    ///     Err(SymplexError::FreeSymbol { .. })
1689    /// ));
1690    /// ```
1691    pub fn find_root_bracket(&self, var: &Ex, a: f64, b: f64) -> Result<f64, SymplexError> {
1692        self.find_root_bracket_with(var, a, b, &RootOpts::default())
1693    }
1694
1695    /// [`find_root_bracket`](Self::find_root_bracket) with explicit
1696    /// [`RootOpts`].
1697    ///
1698    /// # Examples
1699    ///
1700    /// ```
1701    /// use symplex::optimize::RootOpts;
1702    /// use symplex::prelude::*;
1703    ///
1704    /// let ctx = Context::new();
1705    /// let x = ctx.symbol("x");
1706    /// let opts = RootOpts { xtol: 1e-6, ..RootOpts::default() };
1707    /// let r = (x.exp() - 3).find_root_bracket_with(&x, 0.0, 2.0, &opts).unwrap();
1708    /// assert!((r - 3f64.ln()).abs() < 1e-6);
1709    /// ```
1710    pub fn find_root_bracket_with(
1711        &self,
1712        var: &Ex,
1713        a: f64,
1714        b: f64,
1715        opts: &RootOpts,
1716    ) -> Result<f64, SymplexError> {
1717        let f = compile_in(self, &[var], "find_root_bracket")?;
1718        brent_root(|x| f.call(&[x]), a, b, opts)
1719    }
1720
1721    /// Minimise this expression numerically over `vars` from the starting
1722    /// point `x0` by [`nelder_mead`] with default [`MinimizeOpts`].
1723    ///
1724    /// `x0[i]` is the initial value of `vars[i]`.
1725    ///
1726    /// # Errors
1727    ///
1728    /// * [`SymplexError::InvalidArgument`] if `vars` is empty, a variable is
1729    ///   not a symbol, or `x0.len() != vars.len()`.
1730    /// * [`SymplexError::FreeSymbol`] if the expression contains a symbol
1731    ///   not listed in `vars`.
1732    /// * [`SymplexError::NotImplemented`] if the expression cannot be
1733    ///   compiled to `f64` arithmetic.
1734    ///
1735    /// # Examples
1736    ///
1737    /// ```
1738    /// use symplex::prelude::*;
1739    ///
1740    /// let ctx = Context::new();
1741    /// let (x, y) = (ctx.symbol("x"), ctx.symbol("y"));
1742    /// let bowl = (&x - 1).powi(2) + (&y + 2).powi(2);
1743    /// let r = bowl.minimize_numeric(&[&x, &y], &[0.0, 0.0]).unwrap();
1744    /// assert!(r.converged);
1745    /// assert!((r.x[0] - 1.0).abs() < 1e-6 && (r.x[1] + 2.0).abs() < 1e-6);
1746    /// assert!(r.fun < 1e-12);
1747    /// ```
1748    pub fn minimize_numeric(
1749        &self,
1750        vars: &[&Ex],
1751        x0: &[f64],
1752    ) -> Result<MinimizeResult, SymplexError> {
1753        self.minimize_numeric_with(vars, x0, &MinimizeOpts::default())
1754    }
1755
1756    /// [`minimize_numeric`](Self::minimize_numeric) with explicit
1757    /// [`MinimizeOpts`].
1758    ///
1759    /// # Examples
1760    ///
1761    /// ```
1762    /// use symplex::optimize::MinimizeOpts;
1763    /// use symplex::prelude::*;
1764    ///
1765    /// let ctx = Context::new();
1766    /// let (x, y) = (ctx.symbol("x"), ctx.symbol("y"));
1767    /// let rosen = (1 - &x).powi(2) + 100 * (&y - &x.powi(2)).powi(2);
1768    /// let opts = MinimizeOpts { max_iter: 2000, ..MinimizeOpts::default() };
1769    /// let r = rosen.minimize_numeric_with(&[&x, &y], &[-1.2, 1.0], &opts).unwrap();
1770    /// assert!((r.x[0] - 1.0).abs() < 1e-4 && (r.x[1] - 1.0).abs() < 1e-4);
1771    /// ```
1772    pub fn minimize_numeric_with(
1773        &self,
1774        vars: &[&Ex],
1775        x0: &[f64],
1776        opts: &MinimizeOpts,
1777    ) -> Result<MinimizeResult, SymplexError> {
1778        const OP: &str = "minimize_numeric";
1779        if x0.len() != vars.len() {
1780            return Err(invalid(
1781                OP,
1782                format!(
1783                    "initial point has {} entries, expected {}",
1784                    x0.len(),
1785                    vars.len()
1786                ),
1787            ));
1788        }
1789        let f = compile_in(self, vars, OP)?;
1790        nelder_mead(|x| f.call(x), x0, opts)
1791    }
1792
1793    /// Minimise this expression in the single variable `var` over `[a, b]`
1794    /// by Brent's method ([`minimize_scalar`]) with default
1795    /// [`MinimizeOpts`].  Returns `(x_min, f_min)`.
1796    ///
1797    /// # Errors
1798    ///
1799    /// As for [`find_root_bracket`](Self::find_root_bracket) plus the
1800    /// interval rules of [`minimize_scalar`].
1801    ///
1802    /// # Examples
1803    ///
1804    /// ```
1805    /// use symplex::prelude::*;
1806    ///
1807    /// let ctx = Context::new();
1808    /// let x = ctx.symbol("x");
1809    /// // x·ln x has its minimum −1/e at x = 1/e.
1810    /// let (xm, fm) = (&x * x.ln()).minimize_scalar_numeric(&x, 0.1, 2.0).unwrap();
1811    /// assert!((xm - (-1.0f64).exp()).abs() < 1e-6);
1812    /// assert!((fm + (-1.0f64).exp()).abs() < 1e-12);
1813    /// ```
1814    pub fn minimize_scalar_numeric(
1815        &self,
1816        var: &Ex,
1817        a: f64,
1818        b: f64,
1819    ) -> Result<(f64, f64), SymplexError> {
1820        let f = compile_in(self, &[var], "minimize_scalar_numeric")?;
1821        minimize_scalar(|x| f.call(&[x]), a, b, &MinimizeOpts::default())
1822    }
1823
1824    /// Globally minimise this expression over the box `bounds` (one
1825    /// `(lo, hi)` pair per entry of `vars`) by
1826    /// [`differential_evolution`].
1827    ///
1828    /// # Errors
1829    ///
1830    /// As for [`minimize_numeric`](Self::minimize_numeric), with
1831    /// `bounds.len()` playing the role of `x0.len()`, plus the option and
1832    /// bound rules of [`differential_evolution`].
1833    ///
1834    /// # Examples
1835    ///
1836    /// ```
1837    /// use symplex::optimize::DeOpts;
1838    /// use symplex::prelude::*;
1839    ///
1840    /// let ctx = Context::new();
1841    /// let (x, y) = (ctx.symbol("x"), ctx.symbol("y"));
1842    /// // Himmelblau's function has four global minima with f = 0.
1843    /// let h = (&x.powi(2) + &y - 11).powi(2) + (&x + &y.powi(2) - 7).powi(2);
1844    /// let r = h.minimize_global_numeric(&[&x, &y], &[(-5.0, 5.0), (-5.0, 5.0)], &DeOpts::default()).unwrap();
1845    /// assert!(r.fun < 1e-8, "f = {}", r.fun);
1846    /// ```
1847    pub fn minimize_global_numeric(
1848        &self,
1849        vars: &[&Ex],
1850        bounds: &[(f64, f64)],
1851        opts: &DeOpts,
1852    ) -> Result<MinimizeResult, SymplexError> {
1853        const OP: &str = "minimize_global_numeric";
1854        if bounds.len() != vars.len() {
1855            return Err(invalid(
1856                OP,
1857                format!("got {} bounds for {} variables", bounds.len(), vars.len()),
1858            ));
1859        }
1860        let f = compile_in(self, vars, OP)?;
1861        differential_evolution(|x| f.call(x), bounds, opts)
1862    }
1863
1864    /// Exact least-squares polynomial of degree `degree` in `var` through
1865    /// the rational points `(x, y)`.
1866    ///
1867    /// Each coordinate is constant-folded with [`eval`](Self::eval) and
1868    /// must then be a rational literal (`ctx.int`, `ctx.rational`,
1869    /// `sqrt(4)`, …).  The fit is computed by [`poly_fit_exact`], so the
1870    /// result is the exact least-squares polynomial — the interpolating
1871    /// polynomial when `degree + 1 == points.len()` or the data are
1872    /// consistent.
1873    ///
1874    /// # Errors
1875    ///
1876    /// * [`SymplexError::InvalidArgument`] if a coordinate is not a rational
1877    ///   literal after evaluation, or `degree >= points.len()`.
1878    /// * [`SymplexError::ComputationFailed`] if the normal equations are
1879    ///   singular (fewer than `degree + 1` distinct abscissae).
1880    ///
1881    /// # Panics
1882    ///
1883    /// Panics if `var` or a point belongs to a different context than
1884    /// `ctx` (the standard cross-context guard).
1885    ///
1886    /// # Examples
1887    ///
1888    /// ```
1889    /// use symplex::prelude::*;
1890    ///
1891    /// let ctx = Context::new();
1892    /// let x = ctx.symbol("x");
1893    /// // Five samples of x²/3 − x/2 + 1/7.
1894    /// let pts = [
1895    ///     (ctx.int(0), ctx.rational(1, 7)),
1896    ///     (ctx.int(1), ctx.rational(-1, 42)),
1897    ///     (ctx.int(2), ctx.rational(10, 21)),
1898    ///     (ctx.int(3), ctx.rational(23, 14)),
1899    ///     (ctx.int(4), ctx.rational(73, 21)),
1900    /// ];
1901    /// let p = Ex::poly_fit_points(&ctx, &pts, &x, 2).unwrap();
1902    /// let expected = &x.powi(2) * ctx.rational(1, 3) - &x * ctx.rational(1, 2) + ctx.rational(1, 7);
1903    /// assert!((&p - &expected).expand().is_zero_structural(), "{p}");
1904    ///
1905    /// // A symbolic coordinate is rejected.
1906    /// let a = ctx.symbol("a");
1907    /// assert!(Ex::poly_fit_points(&ctx, &[(ctx.int(0), a), (ctx.int(1), ctx.int(1))], &x, 1).is_err());
1908    /// ```
1909    pub fn poly_fit_points(
1910        ctx: &Context,
1911        points: &[(Ex, Ex)],
1912        var: &Ex,
1913        degree: usize,
1914    ) -> Result<Ex, SymplexError> {
1915        const OP: &str = "poly_fit_points";
1916        let _ = ctx.own_id(var);
1917        let to_ratio = |e: &Ex| -> Result<Ratio<BigInt>, SymplexError> {
1918            let _ = var.checked_id(e);
1919            e.eval().as_rational().ok_or_else(|| {
1920                invalid(
1921                    OP,
1922                    format!("point coordinate `{e}` is not a rational literal"),
1923                )
1924            })
1925        };
1926        let mut pts: Vec<(Ratio<BigInt>, Ratio<BigInt>)> = Vec::with_capacity(points.len());
1927        for (px, py) in points {
1928            pts.push((to_ratio(px)?, to_ratio(py)?));
1929        }
1930        let coeffs = poly_fit_exact(&pts, degree)?;
1931        let mut terms: Vec<Ex> = Vec::with_capacity(coeffs.len());
1932        for (i, c) in coeffs.into_iter().enumerate() {
1933            if c.is_zero() {
1934                continue;
1935            }
1936            let power =
1937                i64::try_from(i).map_err(|_| invalid(OP, format!("degree {i} is too large")))?;
1938            terms.push(ctx.from_ratio(c) * var.powi(power));
1939        }
1940        Ok(ctx.sum(&terms))
1941    }
1942}
1943
1944// ═══════════════════════════════════════════════════════════════════════════
1945// Tests
1946// ═══════════════════════════════════════════════════════════════════════════
1947
1948#[cfg(test)]
1949mod tests {
1950    use super::*;
1951
1952    #[test]
1953    fn splitmix_is_deterministic_and_in_range() {
1954        let mut a = SplitMix64::new(42);
1955        let mut b = SplitMix64::new(42);
1956        for _ in 0..100 {
1957            let u = a.next_f64();
1958            assert_eq!(u, b.next_f64());
1959            assert!((0.0..1.0).contains(&u));
1960            let k = a.below(7);
1961            assert_eq!(k, b.below(7));
1962            assert!(k < 7);
1963        }
1964        assert_eq!(SplitMix64::new(0).below(0), 0);
1965    }
1966
1967    #[test]
1968    fn below_excluding_never_returns_excluded() {
1969        let mut rng = SplitMix64::new(7);
1970        for _ in 0..1000 {
1971            let i = rng.below(10);
1972            let r1 = rng.below_excluding(10, &mut [i]);
1973            assert_ne!(r1, i);
1974            let r2 = rng.below_excluding(10, &mut [i, r1]);
1975            assert!(r2 != i && r2 != r1);
1976            let r3 = rng.below_excluding(10, &mut [i, r1, r2]);
1977            assert!(r3 != i && r3 != r1 && r3 != r2 && r3 < 10);
1978        }
1979    }
1980
1981    #[test]
1982    fn shuffle_is_a_permutation() {
1983        let mut rng = SplitMix64::new(3);
1984        let mut v: Vec<usize> = (0..20).collect();
1985        rng.shuffle(&mut v);
1986        let mut sorted = v.clone();
1987        sorted.sort_unstable();
1988        assert_eq!(sorted, (0..20).collect::<Vec<_>>());
1989        assert_ne!(v, sorted, "20 elements should not stay in order");
1990    }
1991
1992    #[test]
1993    fn householder_solves_square_system() {
1994        let a = vec![vec![2.0, 1.0], vec![1.0, 3.0]];
1995        let x = lstsq_householder(a, vec![3.0, 5.0], 2).unwrap();
1996        assert!((x[0] - 0.8).abs() < 1e-12 && (x[1] - 1.4).abs() < 1e-12);
1997    }
1998
1999    #[test]
2000    fn householder_detects_rank_deficiency() {
2001        let a = vec![vec![1.0, 2.0], vec![2.0, 4.0], vec![3.0, 6.0]];
2002        assert!(lstsq_householder(a, vec![1.0, 2.0, 3.0], 2).is_none());
2003    }
2004
2005    #[test]
2006    fn exact_solver_basic_and_singular() {
2007        let q = |n: i64| Ratio::from_integer(BigInt::from(n));
2008        let a = vec![vec![q(2), q(1)], vec![q(1), q(3)]];
2009        let x = solve_exact(a, vec![q(3), q(5)]).unwrap();
2010        assert_eq!(x[0], Ratio::new(BigInt::from(4), BigInt::from(5)));
2011        assert_eq!(x[1], Ratio::new(BigInt::from(7), BigInt::from(5)));
2012        let s = vec![vec![q(1), q(2)], vec![q(2), q(4)]];
2013        assert!(solve_exact(s, vec![q(1), q(2)]).is_none());
2014    }
2015
2016    #[test]
2017    fn argmin_picks_first_smallest() {
2018        assert_eq!(argmin(&[3.0, 1.0, 1.0, 2.0]), 1);
2019        assert_eq!(argmin(&[]), 0);
2020        assert_eq!(argmin(&[f64::INFINITY, f64::INFINITY]), 0);
2021    }
2022}