Skip to main content

secs_dsp/
optimize.rs

1//! Adapted from Impulcifer-py313 `crates/impulcifer-dsp/src/optimize.rs` (MIT, Copyright (c) 2024- 115dkk).
2//! See docs/THIRD_PARTY.md for attribution and licenses.
3//! Bounded TRF, exact SVD solver, linear loss and bounded two-point differences.
4//!
5//! Port of SciPy 1.18.1 optimize/_lsq/{trf,common}.py and _numdiff.py,
6//! Copyright SciPy Developers, BSD-3-Clause (see docs/THIRD_PARTY.md).
7//! The dense exact-SVD solver supports x_scale=1; the normal-equation and opt-in
8//! matrix-free solvers also support x_scale='jac'. All require linear loss and
9//! at least one finite bound. The operator solver uses truncated CG for its
10//! subproblem and intentionally does not promise the exact solver's trajectory.
11//! Default ftol=xtol=gtol=1e-8; custom/disabled tolerances are explicit options. Jacobian evaluations
12//! are deliberately excluded from nfev, as in scipy.optimize.least_squares.
13mod de;
14mod normal;
15mod operator;
16pub use de::{DifferentialEvolutionOptions, DifferentialEvolutionResult, differential_evolution};
17pub use normal::{
18    NormalEquations, NormalOptions, NormalTrace, NormalTrial, XScale, least_squares_normal,
19    least_squares_normal_with_trace, trf_normal_one_step,
20};
21pub use operator::{JacobianOperator, OperatorOptions, least_squares_operator};
22
23use crate::DspError;
24use faer::Mat;
25
26#[derive(Debug)]
27pub struct LeastSquaresResult {
28    pub x: Vec<f64>,
29    pub cost: f64,
30    pub nfev: usize,
31    pub status: i32,
32    pub njev: usize,
33    pub optimality: f64,
34}
35/// Optional diagnostics; main evaluations exclude the initial residual and
36/// Jacobian probes, matching SciPy's trf_bounds call boundary.
37#[derive(Debug, Default)]
38pub struct LeastSquaresTrace {
39    /// Every residual call, including initial and two-point probes.
40    pub evaluations: Vec<Evaluation>,
41    /// Every Jacobian base point, including the initial point.
42    pub jacobian_points: Vec<Vec<f64>>,
43    pub main_x: Vec<Vec<f64>>,
44    pub main_cost: Vec<f64>,
45    /// Jacobian points inside TRF; the separately supplied initial J0 is excluded.
46    pub jac_x: Vec<Vec<f64>>,
47    pub j0: Vec<f64>,
48    pub j0_shape: [usize; 2],
49    pub initial_cost: f64,
50    pub trials: Vec<TrfTrial>,
51}
52#[derive(Debug)]
53pub struct Evaluation {
54    pub x: Vec<f64>,
55    pub cost: f64,
56    pub column: Option<usize>,
57}
58/// Exact-TRF termination tolerances. None disables one test.
59#[derive(Debug, Clone, Copy)]
60pub struct Options {
61    pub max_nfev: usize,
62    pub ftol: Option<f64>,
63    pub xtol: Option<f64>,
64    pub gtol: Option<f64>,
65}
66impl Default for Options {
67    fn default() -> Self {
68        Self {
69            max_nfev: 100,
70            ftol: Some(1e-8),
71            xtol: Some(1e-8),
72            gtol: Some(1e-8),
73        }
74    }
75}
76#[derive(Debug, Default, serde::Serialize)]
77pub struct TrfTrial {
78    pub iteration: usize,
79    pub x: Vec<f64>,
80    pub f: Vec<f64>,
81    /// Row-major unscaled Jacobian at this trial's base point.
82    #[serde(rename = "J")]
83    pub j: Vec<f64>,
84    pub g: Vec<f64>,
85    pub d: Vec<f64>,
86    #[serde(rename = "J_h")]
87    pub j_h: Vec<f64>,
88    pub diag_h: Vec<f64>,
89    pub g_h: Vec<f64>,
90    pub theta: f64,
91    /// Row-major right singular vectors; column signs match `uf`.
92    #[serde(rename = "V")]
93    pub right_vectors: Vec<f64>,
94    pub g_norm: f64,
95    pub v: Vec<f64>,
96    pub dv: Vec<f64>,
97    #[serde(rename = "Delta")]
98    pub delta: f64,
99    pub s: Vec<f64>,
100    pub uf: Vec<f64>,
101    pub alpha_in: f64,
102    pub alpha_out: f64,
103    pub n_iter: usize,
104    pub full_rank: bool,
105    pub p_h: Vec<f64>,
106    pub select: SelectTrace,
107    pub step: Vec<f64>,
108    pub step_h: Vec<f64>,
109    pub x_new: Vec<f64>,
110    pub actual_reduction: f64,
111    pub predicted_reduction: f64,
112    pub ratio: f64,
113    #[serde(rename = "Delta_new")]
114    pub delta_new: f64,
115    pub accepted: bool,
116    pub jacobian_recomputed: bool,
117}
118#[derive(Debug, Default, serde::Serialize)]
119pub struct SelectTrace {
120    pub branch: &'static str,
121    pub p_value: Option<f64>,
122    pub r_value: Option<f64>,
123    pub ag_value: Option<f64>,
124    pub p_stride: Option<f64>,
125    pub r_stride: Option<f64>,
126    pub r_stride_l: Option<f64>,
127    pub r_stride_u: Option<f64>,
128    pub ag_stride: Option<f64>,
129    pub to_tr: Option<f64>,
130    pub to_bound: Option<f64>,
131}
132// A single long accumulator perturbs the anti-gradient line minimum enough
133// to change its next finite-difference Jacobian (the replay pins that case).
134// Independent lanes and a balanced merge reduce that error without FMA or BLAS.
135fn dot(a: &[f64], b: &[f64]) -> f64 {
136    let mut sums = [0.; 8];
137    let chunks = a.len() / 8 * 8;
138    for i in (0..chunks).step_by(8) {
139        for k in 0..8 {
140            sums[k] += a[i + k] * b[i + k];
141        }
142    }
143    let mut sum =
144        ((sums[0] + sums[1]) + (sums[2] + sums[3])) + ((sums[4] + sums[5]) + (sums[6] + sums[7]));
145    for i in chunks..a.len() {
146        sum += a[i] * b[i];
147    }
148    sum
149}
150fn norm(a: &[f64]) -> f64 {
151    dot(a, a).sqrt()
152}
153fn row_major(a: &Mat<f64>) -> Vec<f64> {
154    (0..a.nrows())
155        .flat_map(|i| (0..a.ncols()).map(move |j| a[(i, j)]))
156        .collect()
157}
158fn mv(a: &Mat<f64>, x: &[f64]) -> Vec<f64> {
159    (0..a.nrows())
160        .map(|i| (0..a.ncols()).map(|j| a[(i, j)] * x[j]).sum())
161        .collect()
162}
163fn grad(a: &Mat<f64>, f: &[f64]) -> Vec<f64> {
164    (0..a.ncols())
165        .map(|j| (0..a.nrows()).map(|i| a[(i, j)] * f[i]).sum())
166        .collect()
167}
168fn feasible(x: &mut [f64], lb: &[f64], ub: &[f64], initial: bool) {
169    for ((x, &l), &u) in x.iter_mut().zip(lb).zip(ub) {
170        if initial {
171            let ld = *x - l;
172            let ud = u - *x;
173            if l.is_finite() && ld <= ud.min(1e-10 * l.abs().max(1.)) {
174                *x = l + 1e-10 * l.abs().max(1.);
175            }
176            if u.is_finite() && ud <= ld.min(1e-10 * u.abs().max(1.)) {
177                *x = u - 1e-10 * u.abs().max(1.);
178            }
179        } else {
180            if *x <= l {
181                *x = l.next_up();
182            } else if *x >= u {
183                *x = u.next_down();
184            }
185        }
186        if *x < l || *x > u {
187            *x = 0.5 * (l + u);
188        }
189    }
190}
191fn jacobian<F: FnMut(&[f64], Option<usize>, &mut Vec<f64>)>(
192    fun: &mut F,
193    x: &[f64],
194    f: &[f64],
195    lb: &[f64],
196    ub: &[f64],
197) -> Result<Mat<f64>, DspError> {
198    let mut j = Mat::zeros(f.len(), x.len());
199    let mut y = x.to_vec();
200    let mut fy = vec![0.; f.len()];
201    for k in 0..x.len() {
202        let mut h = f64::EPSILON.sqrt() * if x[k] >= 0. { 1. } else { -1. } * x[k].abs().max(1.);
203        let lower = x[k] - lb[k];
204        let upper = ub[k] - x[k];
205        let fitting = h.abs() <= lower.max(upper);
206        if (x[k] + h < lb[k] || x[k] + h > ub[k]) && fitting {
207            h = -h;
208        }
209        if !fitting {
210            h = if upper >= lower { upper } else { -lower };
211        }
212        y[k] += h;
213        let dx = y[k] - x[k];
214        fun(&y, Some(k), &mut fy);
215        y[k] = x[k];
216        if fy.len() != f.len() || fy.iter().any(|x| !x.is_finite()) {
217            return Err(DspError::InvalidArgument(
218                "nonfinite or inconsistent residual Jacobian".into(),
219            ));
220        }
221        for i in 0..f.len() {
222            j[(i, k)] = (fy[i] - f[i]) / dx;
223        }
224    }
225    Ok(j)
226}
227fn scaling(x: &[f64], g: &[f64], lb: &[f64], ub: &[f64]) -> (Vec<f64>, Vec<f64>) {
228    (0..x.len())
229        .map(|i| {
230            if g[i] < 0. && ub[i].is_finite() {
231                (ub[i] - x[i], -1.)
232            } else if g[i] > 0. && lb[i].is_finite() {
233                (x[i] - lb[i], 1.)
234            } else {
235                (1., 0.)
236            }
237        })
238        .unzip()
239}
240fn bound_step(x: &[f64], s: &[f64], lb: &[f64], ub: &[f64]) -> (f64, Vec<bool>) {
241    let steps: Vec<_> = (0..x.len())
242        .map(|i| {
243            if s[i] == 0. {
244                f64::INFINITY
245            } else {
246                ((lb[i] - x[i]) / s[i]).max((ub[i] - x[i]) / s[i])
247            }
248        })
249        .collect();
250    let t = steps.iter().copied().fold(f64::INFINITY, f64::min);
251    (t, steps.iter().map(|s| *s == t).collect())
252}
253fn quadratic(j: &Mat<f64>, g: &[f64], diag: &[f64], s: &[f64]) -> f64 {
254    let js = mv(j, s);
255    0.5 * (dot(&js, &js) + (0..s.len()).map(|i| s[i] * diag[i] * s[i]).sum::<f64>()) + dot(s, g)
256}
257fn quadratic_line(
258    j: &Mat<f64>,
259    g: &[f64],
260    diag: &[f64],
261    s: &[f64],
262    s0: Option<&[f64]>,
263) -> (f64, f64, f64) {
264    let v = mv(j, s);
265    let a = 0.5 * (dot(&v, &v) + (0..s.len()).map(|i| s[i] * diag[i] * s[i]).sum::<f64>());
266    let mut b = dot(g, s);
267    let mut c = 0.;
268    if let Some(s0) = s0 {
269        let u = mv(j, s0);
270        b += dot(&u, &v);
271        c = 0.5 * dot(&u, &u) + dot(g, s0);
272        b += (0..s.len()).map(|i| s0[i] * diag[i] * s[i]).sum::<f64>();
273        c += 0.5 * (0..s.len()).map(|i| s0[i] * diag[i] * s0[i]).sum::<f64>();
274    }
275    (a, b, c)
276}
277fn minimize((a, b, c): (f64, f64, f64), lo: f64, hi: f64) -> (f64, f64) {
278    let value = |t: f64| t * (a * t + b) + c;
279    let mut best = (lo, value(lo));
280    if value(hi) < best.1 {
281        best = (hi, value(hi));
282    }
283    let e = -0.5 * b / a;
284    if a != 0. && lo < e && e < hi && value(e) < best.1 {
285        best = (e, value(e));
286    }
287    best
288}
289enum QuadraticModel<'a> {
290    Dense(&'a Mat<f64>),
291    Normal(&'a Mat<f64>),
292}
293impl QuadraticModel<'_> {
294    fn value(&self, g: &[f64], diag: &[f64], s: &[f64]) -> f64 {
295        match self {
296            Self::Dense(j) => quadratic(j, g, diag, s),
297            // common.py:evaluate_quadratic: ||J_h s||² = sᵀ H s.
298            Self::Normal(h) => {
299                0.5 * (dot(s, &mv(h, s)) + (0..s.len()).map(|i| s[i] * diag[i] * s[i]).sum::<f64>())
300                    + dot(s, g)
301            }
302        }
303    }
304    fn line(&self, g: &[f64], diag: &[f64], s: &[f64], s0: Option<&[f64]>) -> (f64, f64, f64) {
305        match self {
306            Self::Dense(j) => quadratic_line(j, g, diag, s, s0),
307            // common.py:build_quadratic_1d: (J_h s0)ᵀ(J_h s) = s0ᵀ H s.
308            Self::Normal(h) => {
309                let hs = mv(h, s);
310                let a =
311                    0.5 * (dot(s, &hs) + (0..s.len()).map(|i| s[i] * diag[i] * s[i]).sum::<f64>());
312                let mut b = dot(g, s);
313                let mut c = 0.;
314                if let Some(s0) = s0 {
315                    b += dot(s0, &hs);
316                    c = 0.5 * dot(s0, &mv(h, s0)) + dot(g, s0);
317                    b += (0..s.len()).map(|i| s0[i] * diag[i] * s[i]).sum::<f64>();
318                    c += 0.5 * (0..s.len()).map(|i| s0[i] * diag[i] * s0[i]).sum::<f64>();
319                }
320                (a, b, c)
321            }
322        }
323    }
324}
325#[allow(clippy::too_many_arguments)]
326fn select(
327    x: &[f64],
328    model: QuadraticModel<'_>,
329    diag: &[f64],
330    g: &[f64],
331    mut p: Vec<f64>,
332    mut ph: Vec<f64>,
333    d: &[f64],
334    delta: f64,
335    lb: &[f64],
336    ub: &[f64],
337    theta: f64,
338    mut trace: Option<&mut SelectTrace>,
339) -> (Vec<f64>, Vec<f64>, f64) {
340    if (0..x.len()).all(|i| x[i] + p[i] >= lb[i] && x[i] + p[i] <= ub[i]) {
341        let q = model.value(g, diag, &ph);
342        if let Some(t) = trace {
343            t.branch = "p";
344            t.p_value = Some(q);
345        }
346        return (p, ph, -q);
347    }
348    let (stride, hits) = bound_step(x, &p, lb, ub);
349    let mut rh: Vec<_> = ph
350        .iter()
351        .zip(hits)
352        .map(|(v, h)| if h { -v } else { *v })
353        .collect();
354    let r: Vec<_> = rh.iter().zip(d).map(|(a, b)| a * b).collect();
355    for v in &mut p {
356        *v *= stride;
357    }
358    for v in &mut ph {
359        *v *= stride;
360    }
361    let xb: Vec<_> = x.iter().zip(&p).map(|(a, b)| a + b).collect();
362    let a = dot(&rh, &rh);
363    let b = dot(&ph, &rh);
364    let c = dot(&ph, &ph) - delta * delta;
365    let root = (b * b - a * c).sqrt();
366    let q = -(b + root.copysign(b));
367    let tr = (q / a).max(c / q);
368    let (bound, _) = bound_step(&xb, &r, lb, ub);
369    let rs = bound.min(tr);
370    let (lo, hi) = if rs > 0. {
371        (
372            (1. - theta) * stride / rs,
373            if rs == bound { theta * bound } else { tr },
374        )
375    } else {
376        (0., -1.)
377    };
378    if let Some(t) = trace.as_deref_mut() {
379        t.p_stride = Some(stride);
380        t.r_stride_l = Some(lo);
381        t.r_stride_u = Some(hi);
382        t.r_stride = Some(rs);
383    }
384    let mut rv = f64::INFINITY;
385    if lo <= hi {
386        let (t, v) = minimize(model.line(g, diag, &rh, Some(&ph)), lo, hi);
387        rv = v;
388        if let Some(trace) = trace.as_deref_mut() {
389            trace.r_stride = Some(t);
390        }
391        for i in 0..rh.len() {
392            rh[i] = rh[i] * t + ph[i];
393        }
394    }
395    for v in &mut p {
396        *v *= theta;
397    }
398    for v in &mut ph {
399        *v *= theta;
400    }
401    let pv = model.value(g, diag, &ph);
402    let mut ah: Vec<_> = g.iter().map(|v| -v).collect();
403    let mut ag: Vec<_> = ah.iter().zip(d).map(|(a, b)| a * b).collect();
404    let tr = delta / norm(&ah);
405    let (bound, _) = bound_step(x, &ag, lb, ub);
406    let upper = if bound < tr { theta * bound } else { tr };
407    let (stride, av) = minimize(model.line(g, diag, &ah, None), 0., upper);
408    for v in &mut ah {
409        *v *= stride;
410    }
411    for v in &mut ag {
412        *v *= stride;
413    }
414    if let Some(t) = trace {
415        t.p_value = Some(pv);
416        t.r_value = Some(rv);
417        t.ag_value = Some(av);
418        t.ag_stride = Some(stride);
419        t.to_tr = Some(tr);
420        t.to_bound = Some(bound);
421        t.branch = if pv < rv && pv < av {
422            "p"
423        } else if rv < pv && rv < av {
424            "reflected"
425        } else {
426            "anti-gradient"
427        };
428    }
429    if pv < rv && pv < av {
430        (p, ph, -pv)
431    } else if rv < pv && rv < av {
432        (rh.iter().zip(d).map(|(a, b)| a * b).collect(), rh, -rv)
433    } else {
434        (ag, ah, -av)
435    }
436}
437fn termination(
438    actual: f64,
439    cost: f64,
440    step: &[f64],
441    x: &[f64],
442    ratio: f64,
443    ftol: f64,
444    xtol: f64,
445) -> i32 {
446    let ft = actual < ftol * cost && ratio > 0.25;
447    let xt = norm(step) < xtol * (xtol + norm(x));
448    match (ft, xt) {
449        (true, true) => 4,
450        (true, false) => 2,
451        (false, true) => 3,
452        _ => 0,
453    }
454}
455fn phi_and_derivative(suf: &[f64], s: &[f64], alpha: f64, delta: f64) -> (f64, f64) {
456    let p_norm = norm(
457        &suf.iter()
458            .zip(s)
459            .map(|(u, s)| u / (s * s + alpha))
460            .collect::<Vec<_>>(),
461    );
462    // SciPy uses NumPy power (libm pow for exponent 3), then np.sum's
463    // pairwise reduction, not repeated multiplication and a linear sum.
464    let derivative = -crate::numeric::sum(
465        &suf.iter()
466            .zip(s)
467            .map(|(u, s)| u * u / (s * s + alpha).powf(3.))
468            .collect::<Vec<_>>(),
469    ) / p_norm;
470    (p_norm - delta, derivative)
471}
472fn trust(
473    n: usize,
474    m: usize,
475    uf: &[f64],
476    s: &[f64],
477    v: &Mat<f64>,
478    delta: f64,
479    mut alpha: f64,
480) -> (Vec<f64>, f64, usize, bool) {
481    let suf: Vec<_> = s.iter().zip(uf).map(|(a, b)| a * b).collect();
482    let full = m >= n && s[n - 1] > f64::EPSILON * m as f64 * s[0];
483    if full {
484        let p = mv(
485            v,
486            &uf.iter().zip(s).map(|(a, b)| -a / b).collect::<Vec<_>>(),
487        );
488        if norm(&p) <= delta {
489            return (p, 0., 0, full);
490        }
491    }
492    let phi = |a: f64| phi_and_derivative(&suf, s, a, delta);
493    let mut upper = norm(&suf) / delta;
494    let mut lower = if full {
495        let (p, d) = phi(0.);
496        -p / d
497    } else {
498        0.
499    };
500    if !full && alpha == 0. {
501        alpha = (0.001 * upper).max((lower * upper).sqrt());
502    }
503    let mut n_iter = 0;
504    for _ in 0..10 {
505        n_iter += 1;
506        if alpha < lower || alpha > upper {
507            alpha = (0.001 * upper).max((lower * upper).sqrt());
508        }
509        let (p, dp) = phi(alpha);
510        if p < 0. {
511            upper = alpha;
512        }
513        let ratio = p / dp;
514        lower = lower.max(alpha - ratio);
515        alpha -= (p + delta) * ratio / delta;
516        if p.abs() < 0.01 * delta {
517            break;
518        }
519    }
520    let mut p = mv(
521        v,
522        &suf.iter()
523            .zip(s)
524            .map(|(u, s)| -u / (s * s + alpha))
525            .collect::<Vec<_>>(),
526    );
527    let scale = delta / norm(&p);
528    for v in &mut p {
529        *v *= scale;
530    }
531    (p, alpha, n_iter, full)
532}
533/// SciPy's bounded exact-TRF subset. The closure may cache computations but
534/// must return the same residual at the same x. Bounds may be infinite.
535pub fn least_squares<F: FnMut(&[f64]) -> Vec<f64>>(
536    fun: F,
537    x0: &[f64],
538    lb: &[f64],
539    ub: &[f64],
540    max_nfev: usize,
541) -> Result<LeastSquaresResult, DspError> {
542    least_squares_with_trace(fun, x0, lb, ub, max_nfev, None)
543}
544/// The production solver, with opt-in capture. None allocates no trace storage.
545pub fn least_squares_with_trace<F: FnMut(&[f64]) -> Vec<f64>>(
546    mut fun: F,
547    x0: &[f64],
548    lb: &[f64],
549    ub: &[f64],
550    max_nfev: usize,
551    trace: Option<&mut LeastSquaresTrace>,
552) -> Result<LeastSquaresResult, DspError> {
553    least_squares_with_options(
554        |x| fun(x),
555        x0,
556        lb,
557        ub,
558        Options {
559            max_nfev,
560            ..Options::default()
561        },
562        trace,
563    )
564}
565/// Bounded solver with optional complete trace. At least one finite bound is
566/// required: unconstrained SciPy uses a different algorithm and is not silently
567/// routed through this bounded port. Residual length changes/nonfinite initial
568/// or Jacobian values return Err; nonfinite trial residuals shrink the radius.
569pub fn least_squares_with_options<F: FnMut(&[f64]) -> Vec<f64>>(
570    residual: F,
571    x0: &[f64],
572    lb: &[f64],
573    ub: &[f64],
574    options: Options,
575    trace: Option<&mut LeastSquaresTrace>,
576) -> Result<LeastSquaresResult, DspError> {
577    solve(residual, x0, lb, ub, options, trace, None, None)
578}
579
580/// Dense analytic-Jacobian reference path, sharing the original exact-SVD loop.
581#[doc(hidden)]
582pub fn least_squares_analytic<F, J>(
583    residual: F,
584    mut jac: J,
585    x0: &[f64],
586    lb: &[f64],
587    ub: &[f64],
588    options: Options,
589    trace: Option<&mut LeastSquaresTrace>,
590) -> Result<LeastSquaresResult, DspError>
591where
592    F: FnMut(&[f64]) -> Vec<f64>,
593    J: FnMut(&[f64]) -> Mat<f64>,
594{
595    solve(residual, x0, lb, ub, options, trace, None, Some(&mut jac))
596}
597
598/// Replay one trial of the production loop at an oracle base point. Recomputes
599/// f and the bounded two-point J, including on rejected-trial retries, but does
600/// not perturb x with initial feasibility adjustment or apply the gtol exit.
601/// `jacobian_recomputed` describes the update after this trial, not that setup.
602#[doc(hidden)]
603pub fn trf_one_step<F: FnMut(&[f64]) -> Vec<f64>>(
604    residual: F,
605    x: &[f64],
606    lb: &[f64],
607    ub: &[f64],
608    delta: f64,
609    alpha: f64,
610) -> Result<TrfTrial, DspError> {
611    if !delta.is_finite() || delta <= 0. || !alpha.is_finite() || alpha < 0. {
612        return Err(DspError::InvalidArgument(
613            "invalid replay radius/alpha".into(),
614        ));
615    }
616    let mut trace = LeastSquaresTrace::default();
617    solve(
618        residual,
619        x,
620        lb,
621        ub,
622        Options {
623            max_nfev: 2,
624            gtol: None,
625            ..Options::default()
626        },
627        Some(&mut trace),
628        Some((delta, alpha)),
629        None,
630    )?;
631    trace
632        .trials
633        .pop()
634        .ok_or_else(|| DspError::InvalidArgument("no replay trial".into()))
635}
636
637type AnalyticJacobian<'a> = dyn FnMut(&[f64]) -> Mat<f64> + 'a;
638
639#[allow(clippy::too_many_arguments)]
640fn solve<F: FnMut(&[f64]) -> Vec<f64>>(
641    mut residual: F,
642    x0: &[f64],
643    lb: &[f64],
644    ub: &[f64],
645    options: Options,
646    mut trace: Option<&mut LeastSquaresTrace>,
647    replay: Option<(f64, f64)>,
648    mut analytic: Option<&mut AnalyticJacobian<'_>>,
649) -> Result<LeastSquaresResult, DspError> {
650    let max_nfev = options.max_nfev;
651    let ftol = options.ftol.unwrap_or(0.);
652    let xtol = options.xtol.unwrap_or(0.);
653    let gtol = options.gtol.unwrap_or(0.);
654    if [ftol, xtol, gtol].iter().any(|v| !v.is_finite() || *v < 0.)
655        || [ftol, xtol, gtol].iter().all(|v| *v <= f64::EPSILON)
656    {
657        return Err(DspError::InvalidArgument(
658            "invalid stopping tolerances".into(),
659        ));
660    }
661    if !lb.iter().chain(ub).any(|v| v.is_finite()) {
662        return Err(DspError::InvalidArgument(
663            "bounded TRF requires at least one finite bound".into(),
664        ));
665    }
666    let evaluations = std::cell::RefCell::new(Vec::new());
667    let capture = trace.is_some();
668    let mut fun = |x: &[f64], column: Option<usize>, out: &mut Vec<f64>| {
669        *out = residual(x);
670        if capture {
671            evaluations.borrow_mut().push(Evaluation {
672                x: x.to_vec(),
673                cost: 0.5 * dot(out, out),
674                column,
675            });
676        }
677    };
678    if let Some(t) = trace.as_deref_mut() {
679        *t = LeastSquaresTrace::default();
680    }
681    let n = x0.len();
682    if n == 0
683        || lb.len() != n
684        || ub.len() != n
685        || max_nfev == 0
686        || (0..n).any(|i| {
687            !x0[i].is_finite()
688                || lb[i].is_nan()
689                || ub[i].is_nan()
690                || lb[i] >= ub[i]
691                || x0[i] < lb[i]
692                || x0[i] > ub[i]
693        })
694    {
695        return Err(DspError::InvalidArgument(
696            "invalid least-squares start/bounds/budget".into(),
697        ));
698    }
699    let mut x = x0.to_vec();
700    if replay.is_none() {
701        feasible(&mut x, lb, ub, true);
702    }
703    let mut f = Vec::new();
704    fun(&x, None, &mut f);
705    let m = f.len();
706    if m == 0 || f.iter().any(|v| !v.is_finite()) {
707        return Err(DspError::InvalidArgument("invalid initial residual".into()));
708    }
709    let mut get_jacobian = |x: &[f64], f: &[f64], fun: &mut _| {
710        if let Some(jac) = analytic.as_mut() {
711            let j = jac(x);
712            if j.nrows() != f.len()
713                || j.ncols() != x.len()
714                || row_major(&j).iter().any(|v| !v.is_finite())
715            {
716                return Err(DspError::InvalidArgument(
717                    "invalid analytic Jacobian".into(),
718                ));
719            }
720            Ok(j)
721        } else {
722            jacobian(fun, x, f, lb, ub)
723        }
724    };
725    let mut j = get_jacobian(&x, &f, &mut fun)?;
726    let mut cost = 0.5 * dot(&f, &f);
727    let mut njev = 1;
728    if let Some(t) = trace.as_deref_mut() {
729        t.jacobian_points.push(x.clone());
730        t.initial_cost = cost;
731        t.j0_shape = [m, n];
732        t.j0 = (0..m)
733            .flat_map(|i| (0..n).map(move |k| (i, k)))
734            .map(|(i, k)| j[(i, k)])
735            .collect();
736    }
737    let mut g = grad(&j, &f);
738    let (v, _) = scaling(&x, &g, lb, ub);
739    let mut delta = norm(
740        &x.iter()
741            .zip(v)
742            .map(|(x, v)| x / v.sqrt())
743            .collect::<Vec<_>>(),
744    );
745    if delta == 0. {
746        delta = 1.;
747    }
748    let mut alpha = 0.;
749    if let Some((oracle_delta, oracle_alpha)) = replay {
750        delta = oracle_delta;
751        alpha = oracle_alpha;
752    }
753    let mut nfev = 1;
754    let mut status = 0;
755    let mut iteration = 0;
756    loop {
757        let (v, dv) = scaling(&x, &g, lb, ub);
758        let gn = g
759            .iter()
760            .zip(&v)
761            .map(|(g, v)| (g * v).abs())
762            .fold(0., f64::max);
763        if gn < gtol {
764            status = 1;
765        }
766        if status != 0 || nfev == max_nfev {
767            break;
768        }
769        let d: Vec<_> = v.iter().map(|v| v.sqrt()).collect();
770        let diag: Vec<_> = g.iter().zip(&dv).map(|(g, v)| g * v).collect();
771        let gh: Vec<_> = d.iter().zip(&g).map(|(d, g)| d * g).collect();
772        let jh = Mat::from_fn(m, n, |i, k| j[(i, k)] * d[k]);
773        let aug = Mat::from_fn(m + n, n, |i, k| {
774            if i < m {
775                jh[(i, k)]
776            } else if i - m == k {
777                diag[k].sqrt()
778            } else {
779                0.
780            }
781        });
782        // Explicit Seq also survives downstream Cargo feature unification.
783        use faer::{
784            Par,
785            diag::Diag,
786            dyn_stack::{MemBuffer, MemStack},
787            linalg::svd::{self, ComputeSvdVectors},
788        };
789        let mut u = Mat::zeros(m + n, n);
790        let mut vv = Mat::zeros(n, n);
791        let mut singular = Diag::zeros(n);
792        let mut scratch = MemBuffer::new(svd::svd_scratch::<f64>(
793            m + n,
794            n,
795            ComputeSvdVectors::Thin,
796            ComputeSvdVectors::Thin,
797            Par::Seq,
798            Default::default(),
799        ));
800        svd::svd(
801            aug.as_ref(),
802            singular.as_mut(),
803            Some(u.as_mut()),
804            Some(vv.as_mut()),
805            Par::Seq,
806            MemStack::new(&mut scratch),
807            Default::default(),
808        )
809        .map_err(|_| DspError::InvalidArgument("TRF SVD did not converge".into()))?;
810        let s: Vec<_> = (0..n).map(|i| singular.column_vector()[i]).collect();
811        let uf: Vec<_> = (0..n)
812            .map(|k| (0..m).map(|i| u[(i, k)] * f[i]).sum())
813            .collect();
814        let theta = 0.995_f64.max(1. - gn);
815        let mut actual = -1.;
816        let mut xn = x.clone();
817        let mut fnn = f.clone();
818        let mut cn = cost;
819        while actual <= 0. && nfev < max_nfev {
820            let (ph, a, n_iter, full_rank) = trust(n, m, &uf, &s, &vv, delta, alpha);
821            let mut trial = trace.as_ref().map(|_| TrfTrial {
822                iteration,
823                x: x.clone(),
824                f: f.clone(),
825                j: row_major(&j),
826                g: g.clone(),
827                d: d.clone(),
828                j_h: row_major(&jh),
829                diag_h: diag.clone(),
830                g_h: gh.clone(),
831                theta,
832                right_vectors: row_major(&vv),
833                g_norm: gn,
834                v: v.clone(),
835                dv: dv.clone(),
836                delta,
837                s: s.clone(),
838                uf: uf.clone(),
839                alpha_in: alpha,
840                alpha_out: a,
841                n_iter,
842                full_rank,
843                p_h: ph.clone(),
844                ..Default::default()
845            });
846            alpha = a;
847            let p = ph.iter().zip(&d).map(|(a, b)| a * b).collect();
848            let (step, sh, pred) = select(
849                &x,
850                QuadraticModel::Dense(&jh),
851                &diag,
852                &gh,
853                p,
854                ph,
855                &d,
856                delta,
857                lb,
858                ub,
859                theta,
860                trial.as_mut().map(|t| &mut t.select),
861            );
862            xn = x.iter().zip(&step).map(|(a, b)| a + b).collect();
863            feasible(&mut xn, lb, ub, false);
864            fun(&xn, None, &mut fnn);
865            nfev += 1;
866            if let Some(t) = trace.as_deref_mut() {
867                t.main_x.push(xn.clone());
868                t.main_cost.push(0.5 * dot(&fnn, &fnn));
869            }
870            let shn = norm(&sh);
871            if fnn.len() != m {
872                return Err(DspError::InvalidArgument("residual length changed".into()));
873            }
874            if fnn.iter().any(|v| !v.is_finite()) {
875                if let (Some(t), Some(mut trial)) = (trace.as_deref_mut(), trial) {
876                    trial.step = step;
877                    trial.step_h = sh;
878                    trial.x_new = xn.clone();
879                    trial.actual_reduction = f64::NAN;
880                    trial.predicted_reduction = pred;
881                    trial.ratio = f64::NAN;
882                    trial.delta_new = 0.25 * shn;
883                    t.trials.push(trial);
884                }
885                delta = 0.25 * shn;
886                continue;
887            }
888            cn = 0.5 * dot(&fnn, &fnn);
889            actual = cost - cn;
890            let ratio = if pred > 0. {
891                actual / pred
892            } else if pred == 0. && actual == 0. {
893                1.
894            } else {
895                0.
896            };
897            let dn = if ratio < 0.25 {
898                0.25 * shn
899            } else if ratio > 0.75 && shn > 0.95 * delta {
900                2. * delta
901            } else {
902                delta
903            };
904            if let (Some(t), Some(mut trial)) = (trace.as_deref_mut(), trial) {
905                trial.step = step.clone();
906                trial.step_h = sh.clone();
907                trial.x_new = xn.clone();
908                trial.actual_reduction = actual;
909                trial.predicted_reduction = pred;
910                trial.ratio = ratio;
911                trial.delta_new = dn;
912                trial.accepted = actual > 0.;
913                t.trials.push(trial);
914            }
915            status = termination(actual, cost, &step, &x, ratio, ftol, xtol);
916            if status != 0 {
917                break;
918            }
919            alpha *= delta / dn;
920            delta = dn;
921        }
922        if actual > 0. {
923            x = xn;
924            f = fnn;
925            cost = cn;
926            j = get_jacobian(&x, &f, &mut fun)?;
927            njev += 1;
928            if let Some(t) = trace.as_deref_mut() {
929                t.jac_x.push(x.clone());
930                t.jacobian_points.push(x.clone());
931                if let Some(trial) = t.trials.last_mut() {
932                    trial.jacobian_recomputed = true;
933                }
934            }
935            g = grad(&j, &f);
936        }
937        iteration += 1;
938    }
939    let (v, _) = scaling(&x, &g, lb, ub);
940    let optimality = g
941        .iter()
942        .zip(v)
943        .map(|(g, v)| (g * v).abs())
944        .fold(0., f64::max);
945    if let Some(t) = trace {
946        t.evaluations = evaluations.into_inner();
947    }
948    Ok(LeastSquaresResult {
949        x,
950        cost,
951        nfev,
952        status,
953        njev,
954        optimality,
955    })
956}
957
958#[cfg(test)]
959mod tests {
960    #[test]
961    fn newton_derivative_uses_numpy_power_and_pairwise_sum() {
962        let suf: Vec<_> = (1..10).map(|i| i as f64 * 0.173).collect();
963        let s: Vec<_> = (1..10).map(|i| i as f64 * 0.117).collect();
964        let (_, derivative) = super::phi_and_derivative(&suf, &s, 0.17, 1.);
965        // NumPy 2.5.3: -np.sum(suf**2 / (s**2 + alpha)**3) / norm(...).
966        secs_oracle::assert_same(
967            "TRF Newton derivative",
968            &[derivative],
969            &[-10.858296042275914],
970        );
971    }
972
973    #[test]
974    fn feasible_adjacent_bounds_uses_original_active_mask() {
975        let lower = 1.0_f64;
976        let upper = lower.next_up();
977        let mut x = [lower];
978        super::feasible(&mut x, &[lower], &[upper], false);
979        assert_eq!(x[0], upper);
980        super::feasible(&mut x, &[lower], &[upper], false);
981        assert_eq!(x[0], lower);
982    }
983}