Skip to main content

sidereon_core/astro/math/
least_squares.rs

1//! Generic weighted least-squares substrate.
2//!
3//! Domain-free numerical building blocks for nonlinear least-squares fitting:
4//! a forward-difference Jacobian and a trust-region (trf-style) Gauss-Newton
5//! solver. Nothing here knows about GNSS, orbits, or any physical units; the
6//! caller supplies a residual closure `r: R^n -> R^m` and optional diagonal
7//! weights.
8//!
9//! Two distinct numerical regimes live in this module:
10//!
11//! - The residual evaluation and the finite-difference Jacobian are pure
12//!   `f64` arithmetic plus libm `exp`/etc. inside the caller's closure. Their
13//!   operation order is fixed and reproducible, so they reproduce a reference
14//!   implementation (e.g. scipy `approx_derivative`) bit-for-bit when the same
15//!   recipe and the same libm are used.
16//! - The legacy solver's trust-region step uses dense nalgebra operations whose
17//!   last bits depend on the selected backend. The owned
18//!   [`TrustRegionSolve::OwnedGaussianFirstTie`] variant uses fixed-order scalar
19//!   reductions for the complete trust-region assembly and a fixed-order scalar
20//!   factorization, so its arithmetic path is portable across CPU targets.
21//!
22//! Keeping the finite-difference primitive separate from the linear-algebra
23//! step lets callers assert the former to the bit while treating the latter as
24//! a tolerance-bound agreement.
25//!
26//! # Relationship to the `trust-region-least-squares` crate
27//!
28//! The workspace also ships [`trust-region-least-squares`], a standalone,
29//! publishable solver that reproduces SciPy's trust-region-reflective
30//! `least_squares` (its dense unbounded `n = 3`, linear-loss, 2-point-Jacobian
31//! path) bit-for-bit, via an SVD-based iteration with an injectable SVD/BLAS
32//! seam. This module is a *different* algorithm: a Levenberg-damped Gauss-Newton
33//! trust region with no SVD and no reflection, tuned for the GNSS estimation
34//! stack and unconstrained in dimension. The two are deliberately not unified.
35//!
36//! The reason is bit-exactness, not convenience. The only callers of
37//! [`solve_trf`]/[`solve_trf_with`] are the SPP solve and the reduced-orbit fit;
38//! their converged outputs are pinned to bit-exact goldens (SPP's
39//! geometry/clock reference is Skyfield; the reduced-orbit fit is pinned to an
40//! independent Astropy/SciPy oracle arc). RTK and PPP do not use this solver.
41//! Those goldens were produced by *this* iteration's exact floating-point
42//! trajectory; repointing the callers at the SVD/TRF crate's iteration would
43//! change the converged values and break the goldens, because the two solvers
44//! take different steps even when both converge. So there is no
45//! behavior-preserving merge: the public scipy-compatible solver is the crate,
46//! the GNSS-tuned solver is this module, and unifying them is a
47//! golden-rebaselining decision reserved for the repo owner rather than
48//! something to force here.
49//!
50//! [`trust-region-least-squares`]: https://docs.rs/trust-region-least-squares
51
52use nalgebra::{DMatrix, DVector};
53
54use super::portable;
55
56/// Relative finite-difference step for a 2-point (forward) scheme: `sqrt(eps)`
57/// for `f64`, i.e. `2^-26`. This matches scipy's `_eps_for_method` choice for
58/// the `"2-point"` method.
59pub const FD_REL_STEP_2POINT: f64 = 1.4901161193847656e-8; // 0x1.0p-26 == sqrt(2^-52)
60
61/// Default first-order optimality tolerance (scipy `least_squares` `gtol`).
62const TRF_DEFAULT_GTOL: f64 = 1e-10;
63/// Default relative-cost-reduction tolerance (scipy `least_squares` `ftol`).
64const TRF_DEFAULT_FTOL: f64 = 1e-8;
65/// Default relative-step tolerance (scipy `least_squares` `xtol`).
66const TRF_DEFAULT_XTOL: f64 = 1e-8;
67/// Default maximum residual evaluations.
68const TRF_DEFAULT_MAX_NFEV: usize = 300;
69/// Initial Levenberg damping as a fraction of the largest Gauss-Newton normal
70/// diagonal: `mu0 = TRF_INITIAL_DAMPING_SCALE * max_i (J^T J)_ii`.
71const TRF_INITIAL_DAMPING_SCALE: f64 = 1e-3;
72
73/// Per-parameter step pieces for a single forward-difference column, recorded
74/// in evaluation order so they can be inspected or compared against a
75/// reference trace.
76#[derive(Debug, Clone, PartialEq)]
77pub struct FdStep {
78    /// Index of the perturbed parameter.
79    pub param_index: usize,
80    /// `+1.0` if `x0[i] >= 0`, else `-1.0` (the `(x0>=0)*2 - 1` convention;
81    /// note `x0[i] == 0` yields `+1.0`).
82    pub sign_x0: f64,
83    /// Nominal step `rel_step * sign_x0 * max(1, |x0[i]|)`.
84    pub h: f64,
85    /// Effective step after rounding: `(x0[i] + h) - x0[i]`. This is the
86    /// denominator actually used for the column, recomputed rather than reused
87    /// from `h`.
88    pub dx: f64,
89    /// The perturbed parameter vector (only component `i` bumped by `h`).
90    pub x_perturbed: DVector<f64>,
91}
92
93/// Compute the per-parameter forward-difference step pieces for `x0`.
94///
95/// `sign_x0[i] = +1 if x0[i] >= 0 else -1`, `h[i] = rel_step * sign_x0[i] *
96/// max(1, |x0[i]|)`, and the effective step `dx[i] = (x0[i] + h[i]) - x0[i]`.
97/// The post-rounding `dx` is the value used as the column denominator.
98pub fn fd_steps(x0: &DVector<f64>, rel_step: f64) -> Result<Vec<FdStep>, SolveError> {
99    let rel_step = crate::validate::positive_step(rel_step, "rel_step").map_err(map_field_error)?;
100    fd_steps_checked(x0, rel_step)
101}
102
103fn fd_steps_checked(x0: &DVector<f64>, rel_step: f64) -> Result<Vec<FdStep>, SolveError> {
104    fd_steps_checked_with_min_steps(x0, rel_step, None)
105}
106
107fn fd_steps_checked_with_min_steps(
108    x0: &DVector<f64>,
109    rel_step: f64,
110    min_steps: Option<&DVector<f64>>,
111) -> Result<Vec<FdStep>, SolveError> {
112    validate_nonempty_vector(x0, "parameters")?;
113    validate_vector(x0, "parameters")?;
114    if let Some(min_steps) = min_steps {
115        if min_steps.len() != x0.len() {
116            return Err(invalid_input("fd_min_steps", "length mismatch"));
117        }
118        for &min_step in min_steps.iter() {
119            crate::validate::finite_nonneg(min_step, "fd_min_steps").map_err(map_field_error)?;
120        }
121    }
122    let steps = fd_steps_unchecked(x0, rel_step, min_steps);
123    for step in &steps {
124        validate_value(step.h, "fd_step")?;
125        validate_value(step.dx, "fd_step")?;
126        if step.dx == 0.0 {
127            return Err(invalid_input("fd_step", "zero"));
128        }
129        validate_vector(&step.x_perturbed, "perturbed parameters")?;
130    }
131    Ok(steps)
132}
133
134fn fd_steps_unchecked(
135    x0: &DVector<f64>,
136    rel_step: f64,
137    min_steps: Option<&DVector<f64>>,
138) -> Vec<FdStep> {
139    (0..x0.len())
140        .map(|i| {
141            let xi = x0[i];
142            let sign_x0 = if xi >= 0.0 { 1.0 } else { -1.0 };
143            let relative_h = rel_step * xi.abs().max(1.0);
144            let min_h = min_steps.map_or(0.0, |steps| steps[i]);
145            let h = sign_x0 * relative_h.max(min_h);
146            let mut x_perturbed = x0.clone();
147            x_perturbed[i] = xi + h;
148            let dx = x_perturbed[i] - xi;
149            FdStep {
150                param_index: i,
151                sign_x0,
152                h,
153                dx,
154                x_perturbed,
155            }
156        })
157        .collect()
158}
159
160/// Forward (2-point) finite-difference Jacobian of `residual` at `x0`, given a
161/// precomputed `f0 = residual(x0)`.
162///
163/// Column `i` is `(residual(x0 + h_i e_i) - f0) / dx_i`, where `h_i` and `dx_i`
164/// come from [`fd_steps`]. The arithmetic is plain `f64` (no fused multiply-add):
165/// each entry is one subtraction followed by one division, matching scipy's
166/// `approx_derivative` operation order.
167///
168/// `f0` is passed in (rather than recomputed) so the caller controls the base
169/// evaluation and so the same `f0` used elsewhere is reused exactly.
170pub fn jacobian_2point<F>(
171    residual: F,
172    x0: &DVector<f64>,
173    f0: &DVector<f64>,
174) -> Result<DMatrix<f64>, SolveError>
175where
176    F: Fn(&DVector<f64>) -> DVector<f64>,
177{
178    jacobian_2point_checked_with_min_steps(|x| Ok(residual(x)), x0, f0, None)
179}
180
181/// Forward finite-difference Jacobian with per-parameter absolute step floors.
182///
183/// This crate-private variant lets callers that use the same cancellation
184/// protection as the solver derive an exactly matching covariance matrix.
185#[cfg(test)]
186pub(crate) fn jacobian_2point_with_min_steps<F>(
187    residual: F,
188    x0: &DVector<f64>,
189    f0: &DVector<f64>,
190    min_steps: &DVector<f64>,
191) -> Result<DMatrix<f64>, SolveError>
192where
193    F: Fn(&DVector<f64>) -> DVector<f64>,
194{
195    jacobian_2point_checked_with_min_steps(|x| Ok(residual(x)), x0, f0, Some(min_steps))
196}
197
198fn jacobian_2point_checked_with_min_steps<F>(
199    residual: F,
200    x0: &DVector<f64>,
201    f0: &DVector<f64>,
202    min_steps: Option<&DVector<f64>>,
203) -> Result<DMatrix<f64>, SolveError>
204where
205    F: Fn(&DVector<f64>) -> Result<DVector<f64>, SolveError>,
206{
207    validate_nonempty_vector(x0, "parameters")?;
208    validate_vector(x0, "parameters")?;
209    validate_nonempty_vector(f0, "residual")?;
210    validate_vector(f0, "residual")?;
211    let m = f0.len();
212    let n = x0.len();
213    let steps = fd_steps_checked_with_min_steps(x0, FD_REL_STEP_2POINT, min_steps)?;
214    let mut jac = DMatrix::zeros(m, n);
215    for step in &steps {
216        let f1 = residual(&step.x_perturbed)?;
217        validate_nonempty_vector(&f1, "residual")?;
218        validate_vector(&f1, "residual")?;
219        if f1.len() != m {
220            return Err(invalid_input("residual", "length mismatch"));
221        }
222        let i = step.param_index;
223        for row in 0..m {
224            jac[(row, i)] = (f1[row] - f0[row]) / step.dx;
225        }
226    }
227    validate_matrix(&jac, "jacobian")?;
228    Ok(jac)
229}
230
231/// Termination state of a [`solve_trf`] run, mirroring the scipy
232/// `least_squares` status codes for the conditions this solver detects.
233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234pub enum Status {
235    /// `||J^T r||_inf` fell below `gtol` (first-order optimality).
236    GradientTolerance,
237    /// The relative cost reduction fell below `ftol`.
238    CostTolerance,
239    /// The relative step size fell below `xtol`.
240    StepTolerance,
241    /// The maximum number of residual evaluations was reached.
242    MaxEvaluations,
243}
244
245/// Stopping tolerances and evaluation budget for [`solve_trf`].
246#[derive(Debug, Clone, Copy)]
247pub struct SolveOptions {
248    /// First-order optimality tolerance on `||J^T r||_inf`.
249    pub gtol: f64,
250    /// Relative-cost-reduction tolerance.
251    pub ftol: f64,
252    /// Relative-step tolerance.
253    pub xtol: f64,
254    /// Maximum number of residual evaluations.
255    pub max_nfev: usize,
256}
257
258impl Default for SolveOptions {
259    fn default() -> Self {
260        // scipy's defaults for these tolerances.
261        Self {
262            gtol: TRF_DEFAULT_GTOL,
263            ftol: TRF_DEFAULT_FTOL,
264            xtol: TRF_DEFAULT_XTOL,
265            max_nfev: TRF_DEFAULT_MAX_NFEV,
266        }
267    }
268}
269
270/// Result of a [`solve_trf`] run.
271#[derive(Debug, Clone)]
272pub struct LeastSquaresReport {
273    /// Converged parameter vector.
274    pub x: DVector<f64>,
275    /// Residual at `x`.
276    pub residual: DVector<f64>,
277    /// Cost `0.5 * dot(r, r)` at `x`.
278    pub cost: f64,
279    /// Finite-difference Jacobian at `x`.
280    pub jacobian: DMatrix<f64>,
281    /// First-order optimality `||J^T r||_inf` at `x`.
282    pub optimality_inf: f64,
283    /// Number of accepted iterations.
284    pub iterations: usize,
285    /// Why the solve stopped.
286    pub status: Status,
287}
288
289/// How the trust-region subproblem `(J^T J + mu I) dx = -J^T r` is solved at
290/// each iterate. The legacy path retains nalgebra's dense operations. The
291/// owned path uses fixed-order scalar arithmetic for both the subproblem
292/// assembly and factorization.
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
294pub enum TrustRegionSolve {
295    /// nalgebra LU factorization. This is the legacy SPP path: its last bit is
296    /// BLAS/LAPACK-backend dependent, so its converged solution is reproducible
297    /// only to a tight tolerance, not bit-for-bit across machines.
298    #[default]
299    NalgebraLu,
300    /// Owned deterministic Gaussian elimination with partial pivoting and a
301    /// fixed reduction order ([`crate::astro::math::linear::solve_linear_first_tie`])
302    /// for the dense trust-region subproblem `(J^T J + mu I) dx = -J^T r`.
303    /// The normal matrix, gradient, cost, norm, and optimality reductions are
304    /// also fixed-order scalar operations, with no nalgebra LU or black-box
305    /// BLAS in the solve path.
306    OwnedGaussianFirstTie,
307}
308
309/// Error from [`solve_trf`].
310#[derive(Debug, Clone, thiserror::Error)]
311pub enum SolveError {
312    /// The Jacobian is rank-deficient / the trust-region subproblem has no
313    /// usable descent direction (degenerate geometry).
314    #[error("singular or rank-deficient Jacobian: no usable descent direction")]
315    SingularJacobian,
316    /// A boundary input or derived least-squares quantity was malformed.
317    #[error("invalid least-squares {field}: {reason}")]
318    InvalidInput {
319        field: &'static str,
320        reason: &'static str,
321    },
322}
323
324/// Cost `0.5 * dot(r, r)`, a plain fold of `f64` operations.
325pub fn cost(residual: &DVector<f64>) -> Result<f64, SolveError> {
326    validate_nonempty_vector(residual, "residual")?;
327    validate_vector(residual, "residual")?;
328    validate_value(0.5 * dot_scalar(residual, residual), "cost")
329}
330
331// --- Jacobian-derived geometry: covariance and Hessian-trace primitives -----
332
333/// Parameter covariance from a design (Jacobian) matrix via the Gauss-Newton
334/// normal equations: `sigma^2 (J^T J)^-1`.
335///
336/// `jacobian` is an `m x n` design matrix with `m >= n` (at least as many
337/// residuals as parameters). `variance_scale` (`sigma^2`) multiplies the raw
338/// inverse normal matrix: pass the post-fit reduced chi-square to get the fitted
339/// parameter covariance, or `1.0` for the bare `(J^T J)^-1` cofactor.
340///
341/// The covariance is formed from the thin SVD of `J` directly, not from a
342/// factorization of `J^T J`. With `J = U S V^T`, the normal-equation inverse is
343/// `(J^T J)^-1 = V S^-2 V^T`, so the covariance is
344/// `variance_scale * V diag(1/sigma_i^2) V^T`. Going through the SVD of `J`
345/// rather than inverting `J^T J` avoids squaring the condition number: a
346/// full-rank but near-collinear Jacobian (large `cond(J)`) would become
347/// numerically singular under `cond(J^T J) = cond(J)^2`, whereas the SVD path
348/// keeps the conditioning at `cond(J)`. A genuinely rank-deficient Jacobian
349/// (a singular value at or below the relative rank threshold) yields
350/// [`SolveError::SingularJacobian`].
351///
352/// This is the same quantity, and the same construction, that
353/// `scipy.optimize.curve_fit` reports as `pcov` (`pcov = (J^T J)^-1 * s_sq`,
354/// formed from the SVD of `J`); the two agree to a tight tolerance for a
355/// well-conditioned `J` and stay stable as `J` approaches collinearity.
356pub fn normal_covariance(
357    jacobian: &DMatrix<f64>,
358    variance_scale: f64,
359) -> Result<DMatrix<f64>, SolveError> {
360    validate_matrix(jacobian, "jacobian")?;
361    let m = jacobian.nrows();
362    let n = jacobian.ncols();
363    if n == 0 || m == 0 {
364        return Err(invalid_input("jacobian", "empty"));
365    }
366    if m < n {
367        return Err(invalid_input("jacobian", "fewer rows than columns"));
368    }
369    crate::validate::finite_nonneg(variance_scale, "variance_scale").map_err(map_field_error)?;
370
371    // Thin SVD of J (right singular vectors only). cov = variance_scale * V S^-2 V^T.
372    let svd = portable::svd(jacobian, false, true);
373    let v_t = svd.v_t.ok_or(SolveError::SingularJacobian)?;
374    let singular = svd.singular_values;
375
376    // Rank guard: a singular value at or below the relative threshold means the
377    // covariance is unbounded, i.e. the Jacobian is rank-deficient. This is the
378    // SVD analogue of the previous Cholesky-failure check, but it also catches
379    // the near-collinear case that squaring into J^T J would have masked.
380    let singular_values: Vec<f64> = singular.iter().map(|value| value.0).collect();
381    let diagnostics = singular_value_diagnostics(&singular_values, m, n);
382    if diagnostics.rank < n {
383        return Err(SolveError::SingularJacobian);
384    }
385    debug_assert!(diagnostics.condition_number.is_finite());
386
387    // cov[i][j] = variance_scale * sum_k V[i,k] (1/sigma_k^2) V[j,k].
388    // v_t is n x n with row k = the k-th right singular vector, so V[i,k] = v_t[(k, i)].
389    let mut cov = DMatrix::zeros(n, n);
390    for i in 0..n {
391        for j in 0..n {
392            let mut acc = 0.0;
393            for k in 0..n {
394                let inv_s2 = 1.0 / (singular[k].0 * singular[k].0);
395                acc += v_t[(k, i)].0 * v_t[(k, j)].0 * inv_s2;
396            }
397            cov[(i, j)] = acc * variance_scale;
398        }
399    }
400    validate_matrix(&cov, "covariance")?;
401    Ok(cov)
402}
403
404/// Trace of the Gauss-Newton Hessian approximation `J^T J`, i.e. the sum of the
405/// squared column norms of `jacobian`.
406///
407/// No inverse is formed: this is `sum_i ||J[:, i]||^2 == trace(J^T J)`, summed
408/// column-by-column. It equals `numpy.trace(jac.T @ jac)` to a tight tolerance
409/// (the reductions differ only in summation order).
410pub fn hessian_trace(jacobian: &DMatrix<f64>) -> f64 {
411    let n = jacobian.ncols();
412    let m = jacobian.nrows();
413    let mut trace = 0.0;
414    for i in 0..n {
415        let mut col = 0.0;
416        for r in 0..m {
417            let v = jacobian[(r, i)];
418            col += v * v;
419        }
420        trace += col;
421    }
422    trace
423}
424
425#[derive(Debug, Clone, Copy, PartialEq)]
426pub(crate) struct SingularValueDiagnostics {
427    pub(crate) rank: usize,
428    pub(crate) condition_number: f64,
429}
430
431pub(crate) fn singular_value_diagnostics(
432    singular_values: &[f64],
433    rows: usize,
434    cols: usize,
435) -> SingularValueDiagnostics {
436    let smax = singular_values.iter().copied().fold(0.0_f64, f64::max);
437    if smax == 0.0 {
438        return SingularValueDiagnostics {
439            rank: 0,
440            condition_number: f64::INFINITY,
441        };
442    }
443
444    let threshold = smax * (rows.max(cols) as f64) * f64::EPSILON;
445    let rank = singular_values.iter().filter(|&&s| s > threshold).count();
446    let condition_number = if rank < cols {
447        f64::INFINITY
448    } else {
449        let smin = singular_values
450            .iter()
451            .copied()
452            .fold(f64::INFINITY, f64::min);
453        smax / smin
454    };
455
456    SingularValueDiagnostics {
457        rank,
458        condition_number,
459    }
460}
461
462/// Fitted parameter covariance directly from a design (Jacobian) matrix and the
463/// post-fit cost, with the redundancy taken from the Jacobian's own shape.
464///
465/// This is the binding-facing primitive: it forms the covariance straight from
466/// the design matrix and the scalar cost, with no [`LeastSquaresReport`] and no
467/// fabricated residual / parameter vectors. The degrees of freedom come from the
468/// Jacobian's dimensions alone (`m = jacobian.nrows()`, `n = jacobian.ncols()`),
469/// so there are no redundant lengths to keep consistent. It scales `(J^T J)^-1`
470/// by the post-fit reduced chi-square `s_sq = 2 * cost / (m - n)` (the residual
471/// sum of squares over the redundancy), the same scale `scipy.optimize.curve_fit`
472/// applies to its `pcov`. Requires positive redundancy `m > n`; otherwise
473/// returns [`SolveError::InvalidInput`].
474pub fn covariance_from_jacobian(
475    jacobian: &DMatrix<f64>,
476    cost: f64,
477) -> Result<DMatrix<f64>, SolveError> {
478    let m = jacobian.nrows();
479    let n = jacobian.ncols();
480    if m <= n {
481        return Err(invalid_input("degrees_of_freedom", "not positive"));
482    }
483    let dof = (m - n) as f64;
484    let s_sq = validate_value(2.0 * cost / dof, "reduced_chi_square")?;
485    normal_covariance(jacobian, s_sq)
486}
487
488/// Fitted parameter covariance from a converged [`LeastSquaresReport`].
489///
490/// Convenience over [`covariance_from_jacobian`] for real-report callers: it
491/// validates that the report's Jacobian shape agrees with its residual / `x`
492/// lengths, then delegates to [`covariance_from_jacobian`] so the report path
493/// and the Jacobian path share a single reduced-chi-square scaling. Requires
494/// positive redundancy `m > n`; otherwise returns [`SolveError::InvalidInput`].
495pub fn covariance_from_report(report: &LeastSquaresReport) -> Result<DMatrix<f64>, SolveError> {
496    let m = report.residual.len();
497    let n = report.x.len();
498    // `LeastSquaresReport`'s fields are public, so the residual/x lengths that
499    // set the degrees of freedom and the Jacobian that sets the scale can be
500    // inconsistent. Reject a Jacobian whose shape does not match (m x n) rather
501    // than silently scaling a covariance of the Jacobian's dimensions by a
502    // reduced chi-square derived from unrelated vectors.
503    if report.jacobian.nrows() != m {
504        return Err(invalid_input("jacobian", "rows must match residual length"));
505    }
506    if report.jacobian.ncols() != n {
507        return Err(invalid_input(
508            "jacobian",
509            "columns must match parameter length",
510        ));
511    }
512    covariance_from_jacobian(&report.jacobian, report.cost)
513}
514
515/// A nonlinear least-squares problem: a residual closure, optional diagonal
516/// weights, and a starting point. The weighted form scales both the residual
517/// and the Jacobian rows by `sqrt(weight)`; with all weights `1` it reduces to
518/// the ordinary (unweighted) least-squares problem.
519pub struct LeastSquaresProblem<F> {
520    residual: F,
521    /// `sqrt` of the diagonal weights, or `None` for the identity weighting.
522    sqrt_weights: Option<DVector<f64>>,
523    /// Optional per-parameter absolute floors for forward-difference steps.
524    fd_min_steps: Option<DVector<f64>>,
525    x0: DVector<f64>,
526}
527
528impl<F> LeastSquaresProblem<F>
529where
530    F: Fn(&DVector<f64>) -> DVector<f64>,
531{
532    /// An unweighted problem (identity weighting).
533    pub fn new(residual: F, x0: DVector<f64>) -> Self {
534        Self {
535            residual,
536            sqrt_weights: None,
537            fd_min_steps: None,
538            x0,
539        }
540    }
541
542    /// A problem with diagonal weights `W`; residual and Jacobian rows are
543    /// scaled by `sqrt(W)`.
544    pub fn with_weights(residual: F, x0: DVector<f64>, weights: DVector<f64>) -> Self {
545        let sqrt_weights = weights.map(f64::sqrt);
546        Self {
547            residual,
548            sqrt_weights: Some(sqrt_weights),
549            fd_min_steps: None,
550            x0,
551        }
552    }
553
554    /// A weighted problem with per-parameter absolute floors for the
555    /// forward-difference step. A zero floor keeps the relative step.
556    pub fn with_weights_and_fd_min_steps(
557        residual: F,
558        x0: DVector<f64>,
559        weights: DVector<f64>,
560        fd_min_steps: DVector<f64>,
561    ) -> Self {
562        let sqrt_weights = weights.map(f64::sqrt);
563        Self {
564            residual,
565            sqrt_weights: Some(sqrt_weights),
566            fd_min_steps: Some(fd_min_steps),
567            x0,
568        }
569    }
570
571    /// Weighted residual at `x`.
572    fn weighted_residual(&self, x: &DVector<f64>) -> Result<DVector<f64>, SolveError> {
573        validate_nonempty_vector(x, "parameters")?;
574        validate_vector(x, "parameters")?;
575        let r = (self.residual)(x);
576        validate_nonempty_vector(&r, "residual")?;
577        validate_vector(&r, "residual")?;
578        match &self.sqrt_weights {
579            Some(sw) => {
580                validate_nonempty_vector(sw, "weights")?;
581                validate_vector(sw, "weights")?;
582                if sw.len() != r.len() {
583                    return Err(invalid_input("weights", "length mismatch"));
584                }
585                let weighted = r.component_mul(sw);
586                validate_vector(&weighted, "weighted residual")?;
587                Ok(weighted)
588            }
589            None => Ok(r),
590        }
591    }
592
593    fn jacobian(&self, x: &DVector<f64>, f0: &DVector<f64>) -> Result<DMatrix<f64>, SolveError> {
594        jacobian_2point_checked_with_min_steps(
595            |p| self.weighted_residual(p),
596            x,
597            f0,
598            self.fd_min_steps.as_ref(),
599        )
600    }
601}
602
603/// Trust-region (trf-style) Gauss-Newton solve.
604///
605/// At each iterate the weighted residual and its forward-difference Jacobian
606/// are formed, then a Levenberg-damped Gauss-Newton step `(J^T J + mu I) dx =
607/// -J^T r` is taken inside a trust region; the damping `mu` is grown on a
608/// rejected step and shrunk on an accepted one. The linear solve uses a dense
609/// factorization, so the converged solution is reproducible to a tight
610/// tolerance rather than to the bit.
611///
612/// Returns [`SolveError::SingularJacobian`] if the normal-equation system
613/// cannot be solved (degenerate geometry).
614///
615/// Uses the legacy [`TrustRegionSolve::NalgebraLu`] subproblem solver; for the
616/// owned deterministic factorization call [`solve_trf_with`].
617pub fn solve_trf<F>(
618    problem: &LeastSquaresProblem<F>,
619    opts: &SolveOptions,
620) -> Result<LeastSquaresReport, SolveError>
621where
622    F: Fn(&DVector<f64>) -> DVector<f64>,
623{
624    solve_trf_with(problem, opts, TrustRegionSolve::NalgebraLu)
625}
626
627/// Solve the trust-region subproblem `(J^T J + mu I) dx = rhs` with the
628/// selected factorization. The two arms produce the same algebra; they differ
629/// only in the dense solve's operation order (see [`TrustRegionSolve`]).
630fn solve_subproblem(
631    lhs: &DMatrix<f64>,
632    rhs: &DVector<f64>,
633    linear_solve: TrustRegionSolve,
634) -> Option<DVector<f64>> {
635    match linear_solve {
636        TrustRegionSolve::NalgebraLu => portable::solve_lu(lhs, rhs),
637        TrustRegionSolve::OwnedGaussianFirstTie => {
638            let n = rhs.len();
639            let a: Vec<Vec<f64>> = (0..n)
640                .map(|i| (0..n).map(|j| lhs[(i, j)]).collect())
641                .collect();
642            let b: Vec<f64> = rhs.iter().copied().collect();
643            crate::astro::math::linear::solve_linear_first_tie(&a, &b).map(DVector::from_vec)
644        }
645    }
646}
647
648fn normal_matrix_scalar(jacobian: &DMatrix<f64>) -> DMatrix<f64> {
649    let rows = jacobian.nrows();
650    let cols = jacobian.ncols();
651    let mut result = DMatrix::zeros(cols, cols);
652    for column in 0..cols {
653        for other_column in 0..cols {
654            let mut sum = 0.0_f64;
655            for row in 0..rows {
656                sum += jacobian[(row, column)] * jacobian[(row, other_column)];
657            }
658            result[(column, other_column)] = sum;
659        }
660    }
661    result
662}
663
664fn gradient_scalar(jacobian: &DMatrix<f64>, residual: &DVector<f64>) -> DVector<f64> {
665    let rows = jacobian.nrows();
666    let cols = jacobian.ncols();
667    DVector::from_iterator(
668        cols,
669        (0..cols).map(|column| {
670            let mut sum = 0.0_f64;
671            for row in 0..rows {
672                sum += jacobian[(row, column)] * residual[row];
673            }
674            sum
675        }),
676    )
677}
678
679fn dot_scalar(lhs: &DVector<f64>, rhs: &DVector<f64>) -> f64 {
680    let mut sum = 0.0_f64;
681    for index in 0..lhs.len() {
682        sum += lhs[index] * rhs[index];
683    }
684    sum
685}
686
687fn norm_scalar(vector: &DVector<f64>) -> f64 {
688    dot_scalar(vector, vector).sqrt()
689}
690
691fn amax_scalar(vector: &DVector<f64>) -> f64 {
692    vector
693        .iter()
694        .map(|value| value.abs())
695        .fold(0.0_f64, f64::max)
696}
697
698fn add_scalar(lhs: &DVector<f64>, rhs: &DVector<f64>) -> DVector<f64> {
699    DVector::from_iterator(
700        lhs.len(),
701        (0..lhs.len()).map(|index| lhs[index] + rhs[index]),
702    )
703}
704
705/// [`solve_trf`] with an explicit choice of the trust-region subproblem solver.
706/// `NalgebraLu` reproduces the legacy SPP path; `OwnedGaussianFirstTie` is the
707/// owned deterministic kernel with fixed-order scalar assembly and
708/// factorization, pinned to its own frozen-bits goldens.
709pub fn solve_trf_with<F>(
710    problem: &LeastSquaresProblem<F>,
711    opts: &SolveOptions,
712    linear_solve: TrustRegionSolve,
713) -> Result<LeastSquaresReport, SolveError>
714where
715    F: Fn(&DVector<f64>) -> DVector<f64>,
716{
717    validate_options(opts)?;
718    let n = problem.x0.len();
719
720    let mut x = problem.x0.clone();
721    validate_nonempty_vector(&x, "initial parameters")?;
722    validate_vector(&x, "initial parameters")?;
723    let mut r = problem.weighted_residual(&x)?;
724    let mut f0 = r.clone();
725    let mut jac = problem.jacobian(&x, &f0)?;
726    let mut nfev = 1usize; // the f0 above
727    let scalar_reductions = linear_solve == TrustRegionSolve::OwnedGaussianFirstTie;
728    let mut cur_cost = if scalar_reductions {
729        validate_value(0.5 * dot_scalar(&r, &r), "cost")?
730    } else {
731        cost(&r)?
732    };
733
734    // Initial Levenberg damping scaled to the Gauss-Newton normal matrix.
735    let jtj0 = if scalar_reductions {
736        normal_matrix_scalar(&jac)
737    } else {
738        portable::product(&jac.transpose(), &jac)
739    };
740    validate_matrix(&jtj0, "normal matrix")?;
741    let mut mu = TRF_INITIAL_DAMPING_SCALE
742        * (0..n)
743            .map(|i| jtj0[(i, i)])
744            .fold(0.0_f64, f64::max)
745            .max(1.0);
746
747    let mut iterations = 0usize;
748
749    loop {
750        let grad = if scalar_reductions {
751            gradient_scalar(&jac, &r)
752        } else {
753            let jt = jac.transpose();
754            portable::product_vector(&jt, &r)
755        };
756        validate_vector(&grad, "gradient")?;
757        let optimality_inf = validate_value(
758            if scalar_reductions {
759                amax_scalar(&grad)
760            } else {
761                grad.amax()
762            },
763            "optimality",
764        )?;
765
766        if optimality_inf < opts.gtol {
767            return finish(
768                x,
769                r,
770                cur_cost,
771                jac,
772                iterations,
773                Status::GradientTolerance,
774                scalar_reductions,
775            );
776        }
777        if nfev >= opts.max_nfev {
778            return finish(
779                x,
780                r,
781                cur_cost,
782                jac,
783                iterations,
784                Status::MaxEvaluations,
785                scalar_reductions,
786            );
787        }
788
789        let jtj = if scalar_reductions {
790            normal_matrix_scalar(&jac)
791        } else {
792            let jt = jac.transpose();
793            portable::product(&jt, &jac)
794        };
795        validate_matrix(&jtj, "normal matrix")?;
796
797        // Levenberg-damped Gauss-Newton subproblem.
798        let mut accepted = false;
799        for _ in 0..30 {
800            let mut lhs = jtj.clone();
801            for i in 0..n {
802                lhs[(i, i)] += mu;
803            }
804            let rhs = -&grad;
805            validate_matrix(&lhs, "subproblem matrix")?;
806            validate_vector(&rhs, "subproblem rhs")?;
807            let step = match solve_subproblem(&lhs, &rhs, linear_solve) {
808                Some(s) => s,
809                None => return Err(SolveError::SingularJacobian),
810            };
811            validate_vector(&step, "step")?;
812
813            let x_trial = if scalar_reductions {
814                add_scalar(&x, &step)
815            } else {
816                &x + &step
817            };
818            let r_trial = problem.weighted_residual(&x_trial)?;
819            nfev += 1;
820            let cost_trial = if scalar_reductions {
821                validate_value(0.5 * dot_scalar(&r_trial, &r_trial), "cost")?
822            } else {
823                cost(&r_trial)?
824            };
825
826            if cost_trial < cur_cost {
827                // Accept; relative-cost and relative-step stopping checks.
828                let cost_reduction = (cur_cost - cost_trial) / cur_cost.max(f64::MIN_POSITIVE);
829                let step_norm = if scalar_reductions {
830                    norm_scalar(&step)
831                } else {
832                    step.norm()
833                };
834                let x_norm = if scalar_reductions {
835                    norm_scalar(&x)
836                } else {
837                    x.norm()
838                };
839                let rel_step = step_norm / x_norm.max(f64::MIN_POSITIVE);
840
841                x = x_trial;
842                r = r_trial;
843                cur_cost = cost_trial;
844                f0 = r.clone();
845                jac = problem.jacobian(&x, &f0)?;
846                nfev += n; // FD probes for the new Jacobian
847                iterations += 1;
848                mu *= 0.5;
849                accepted = true;
850
851                if cost_reduction < opts.ftol {
852                    return finish(
853                        x,
854                        r,
855                        cur_cost,
856                        jac,
857                        iterations,
858                        Status::CostTolerance,
859                        scalar_reductions,
860                    );
861                }
862                if rel_step < opts.xtol {
863                    return finish(
864                        x,
865                        r,
866                        cur_cost,
867                        jac,
868                        iterations,
869                        Status::StepTolerance,
870                        scalar_reductions,
871                    );
872                }
873                break;
874            } else {
875                // Reject: grow damping and retry the subproblem.
876                mu *= 2.0;
877            }
878        }
879
880        if !accepted {
881            // Could not find an improving step within the damping sweep.
882            return finish(
883                x,
884                r,
885                cur_cost,
886                jac,
887                iterations,
888                Status::StepTolerance,
889                scalar_reductions,
890            );
891        }
892    }
893}
894
895fn finish(
896    x: DVector<f64>,
897    residual: DVector<f64>,
898    cost_value: f64,
899    jacobian: DMatrix<f64>,
900    iterations: usize,
901    status: Status,
902    scalar_reductions: bool,
903) -> Result<LeastSquaresReport, SolveError> {
904    validate_nonempty_vector(&x, "solution")?;
905    validate_vector(&x, "solution")?;
906    validate_nonempty_vector(&residual, "residual")?;
907    validate_vector(&residual, "residual")?;
908    validate_value(cost_value, "cost")?;
909    validate_matrix(&jacobian, "jacobian")?;
910    let optimality_inf = validate_value(
911        if scalar_reductions {
912            amax_scalar(&gradient_scalar(&jacobian, &residual))
913        } else {
914            portable::product_vector(&jacobian.transpose(), &residual).amax()
915        },
916        "optimality",
917    )?;
918    Ok(LeastSquaresReport {
919        x,
920        residual,
921        cost: cost_value,
922        jacobian,
923        optimality_inf,
924        iterations,
925        status,
926    })
927}
928
929fn validate_value(value: f64, field: &'static str) -> Result<f64, SolveError> {
930    crate::validate::finite(value, field).map_err(map_field_error)
931}
932
933fn validate_options(opts: &SolveOptions) -> Result<(), SolveError> {
934    crate::validate::positive_step(opts.gtol, "gtol").map_err(map_field_error)?;
935    crate::validate::positive_step(opts.ftol, "ftol").map_err(map_field_error)?;
936    crate::validate::positive_step(opts.xtol, "xtol").map_err(map_field_error)?;
937    if opts.max_nfev == 0 {
938        return Err(invalid_input("max_nfev", "not positive"));
939    }
940    Ok(())
941}
942
943fn validate_nonempty_vector(vector: &DVector<f64>, field: &'static str) -> Result<(), SolveError> {
944    if vector.is_empty() {
945        Err(invalid_input(field, "empty"))
946    } else {
947        Ok(())
948    }
949}
950
951fn validate_vector(vector: &DVector<f64>, field: &'static str) -> Result<(), SolveError> {
952    crate::validate::finite_slice(vector.as_slice(), field).map_err(map_field_error)
953}
954
955fn validate_matrix(matrix: &DMatrix<f64>, field: &'static str) -> Result<(), SolveError> {
956    crate::validate::finite_slice(matrix.as_slice(), field).map_err(map_field_error)
957}
958
959fn map_field_error(error: crate::validate::FieldError) -> SolveError {
960    invalid_input(error.field(), error.reason())
961}
962
963fn invalid_input(field: &'static str, reason: &'static str) -> SolveError {
964    SolveError::InvalidInput { field, reason }
965}
966
967#[cfg(test)]
968mod tests {
969    use super::*;
970
971    #[test]
972    fn fd_rel_step_is_sqrt_eps() {
973        assert_eq!(FD_REL_STEP_2POINT, (2.0_f64.powi(-52)).sqrt());
974        assert_eq!(FD_REL_STEP_2POINT, 2.0_f64.powi(-26));
975    }
976
977    #[test]
978    fn fd_step_sign_convention() {
979        let x0 = DVector::from_vec(vec![5.0, -2.0, 0.0]);
980        let steps = fd_steps(&x0, FD_REL_STEP_2POINT).unwrap();
981        assert_eq!(steps[0].sign_x0, 1.0);
982        assert_eq!(steps[1].sign_x0, -1.0);
983        assert_eq!(steps[2].sign_x0, 1.0); // x == 0 -> +1
984    }
985
986    #[test]
987    fn fd_steps_rejects_zero_relative_step() {
988        let x0 = DVector::from_vec(vec![1.0]);
989        assert_invalid_field(fd_steps(&x0, 0.0).unwrap_err(), "rel_step");
990    }
991
992    #[test]
993    fn fd_steps_rejects_nonfinite_parameters() {
994        let x0 = DVector::from_vec(vec![1.0, f64::NAN]);
995        assert_invalid_field(fd_steps(&x0, FD_REL_STEP_2POINT).unwrap_err(), "parameters");
996    }
997
998    #[test]
999    fn jacobian_rejects_residual_length_mismatch() {
1000        let x0 = DVector::from_vec(vec![1.0, 2.0]);
1001        let f0 = DVector::from_vec(vec![1.0, 2.0]);
1002        let residual = |_: &DVector<f64>| DVector::from_vec(vec![1.0]);
1003        assert_invalid_field(jacobian_2point(residual, &x0, &f0).unwrap_err(), "residual");
1004    }
1005
1006    #[test]
1007    fn cost_rejects_nonfinite_residual() {
1008        assert_invalid_field(
1009            cost(&DVector::from_vec(vec![1.0, f64::INFINITY])).unwrap_err(),
1010            "residual",
1011        );
1012    }
1013
1014    #[test]
1015    fn exp_fit_converges() {
1016        // a*exp(b*t) + c with a known minimum near the generated data.
1017        let t = vec![0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0];
1018        let y = vec![
1019            3.0123, 2.2083, 1.6889, 1.3713, 1.0903, 0.9302, 0.8104, 0.6303,
1020        ];
1021        let tt = t.clone();
1022        let yy = y.clone();
1023        let residual = move |p: &DVector<f64>| {
1024            let (a, b, c) = (p[0], p[1], p[2]);
1025            DVector::from_iterator(
1026                tt.len(),
1027                tt.iter()
1028                    .zip(&yy)
1029                    .map(|(&tk, &yk)| a * libm::exp(b * tk) + c - yk),
1030            )
1031        };
1032        let problem = LeastSquaresProblem::new(residual, DVector::from_vec(vec![5.0, -2.0, 2.0]));
1033        let report = solve_trf(&problem, &SolveOptions::default()).unwrap();
1034        assert!(report.cost < 1.0, "cost did not reduce: {}", report.cost);
1035    }
1036
1037    #[test]
1038    fn solve_trf_rejects_nonfinite_initial_residual() {
1039        fn residual(_: &DVector<f64>) -> DVector<f64> {
1040            DVector::from_element(1, f64::NAN)
1041        }
1042        let problem = LeastSquaresProblem::new(residual, DVector::from_element(1, 0.0));
1043        assert_invalid_field(
1044            solve_trf(&problem, &SolveOptions::default()).unwrap_err(),
1045            "residual",
1046        );
1047    }
1048
1049    #[test]
1050    fn solve_trf_rejects_nonfinite_initial_cost() {
1051        fn residual(_: &DVector<f64>) -> DVector<f64> {
1052            DVector::from_element(1, f64::MAX)
1053        }
1054        let problem = LeastSquaresProblem::new(residual, DVector::from_element(1, 0.0));
1055        assert_invalid_field(
1056            solve_trf(&problem, &SolveOptions::default()).unwrap_err(),
1057            "cost",
1058        );
1059    }
1060
1061    #[test]
1062    fn solve_trf_rejects_nonfinite_trial_residual_instead_of_converging() {
1063        use std::cell::Cell;
1064
1065        let calls = Cell::new(0usize);
1066        let residual = move |p: &DVector<f64>| {
1067            let call = calls.get();
1068            calls.set(call + 1);
1069            if call >= 2 {
1070                DVector::from_element(1, f64::NAN)
1071            } else {
1072                DVector::from_element(1, p[0])
1073            }
1074        };
1075        let problem = LeastSquaresProblem::new(residual, DVector::from_element(1, 1.0));
1076        assert_invalid_field(
1077            solve_trf(&problem, &SolveOptions::default()).unwrap_err(),
1078            "residual",
1079        );
1080    }
1081
1082    #[test]
1083    fn solve_trf_rejects_invalid_options() {
1084        fn residual(p: &DVector<f64>) -> DVector<f64> {
1085            DVector::from_element(1, p[0])
1086        }
1087        let problem = LeastSquaresProblem::new(residual, DVector::from_element(1, 1.0));
1088        let opts = SolveOptions {
1089            gtol: f64::NAN,
1090            ..SolveOptions::default()
1091        };
1092        assert_invalid_field(solve_trf(&problem, &opts).unwrap_err(), "gtol");
1093
1094        let opts = SolveOptions {
1095            max_nfev: 0,
1096            ..SolveOptions::default()
1097        };
1098        assert_invalid_field(solve_trf(&problem, &opts).unwrap_err(), "max_nfev");
1099    }
1100
1101    #[test]
1102    fn solve_trf_rejects_weight_residual_dimension_mismatch() {
1103        fn residual(_: &DVector<f64>) -> DVector<f64> {
1104            DVector::from_vec(vec![1.0, 2.0])
1105        }
1106        let problem = LeastSquaresProblem::with_weights(
1107            residual,
1108            DVector::from_element(1, 0.0),
1109            DVector::from_vec(vec![1.0]),
1110        );
1111        assert_invalid_field(
1112            solve_trf(&problem, &SolveOptions::default()).unwrap_err(),
1113            "weights",
1114        );
1115    }
1116
1117    fn assert_invalid_field(error: SolveError, expected: &'static str) {
1118        match error {
1119            SolveError::InvalidInput { field, .. } => assert_eq!(field, expected),
1120            other => panic!("expected invalid input for {expected}, got {other:?}"),
1121        }
1122    }
1123
1124    /// The exp-fit residual used by the owned-solver tests.
1125    fn exp_fit_problem() -> LeastSquaresProblem<impl Fn(&DVector<f64>) -> DVector<f64>> {
1126        let t = [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0];
1127        let y = [
1128            3.0123, 2.2083, 1.6889, 1.3713, 1.0903, 0.9302, 0.8104, 0.6303,
1129        ];
1130        let residual = move |p: &DVector<f64>| {
1131            let (a, b, c) = (p[0], p[1], p[2]);
1132            DVector::from_iterator(
1133                t.len(),
1134                t.iter()
1135                    .zip(&y)
1136                    .map(|(&tk, &yk)| a * libm::exp(b * tk) + c - yk),
1137            )
1138        };
1139        LeastSquaresProblem::new(residual, DVector::from_vec(vec![5.0, -2.0, 2.0]))
1140    }
1141
1142    /// The owned deterministic subproblem solver converges on the exp-fit
1143    /// problem and reproduces its solution bit-for-bit run to run. The pinned
1144    /// bits are the owned kernel's own frozen-bits golden (a different
1145    /// factorization than the legacy nalgebra path, so its own value). The
1146    /// owned kernel uses fixed-order scalar arithmetic for the complete
1147    /// trust-region assembly and factorization, so the frozen bits are a
1148    /// cross-platform constant; the run-to-run check below guards that
1149    /// contract.
1150    #[test]
1151    fn owned_trf_converges_to_frozen_bits() {
1152        let problem = exp_fit_problem();
1153        let report = solve_trf_with(
1154            &problem,
1155            &SolveOptions::default(),
1156            TrustRegionSolve::OwnedGaussianFirstTie,
1157        )
1158        .unwrap();
1159        assert!(
1160            report.cost < 1.0,
1161            "owned cost did not reduce: {}",
1162            report.cost
1163        );
1164        assert_eq!(report.x[0].to_bits(), 0x4003c3674cd4b235);
1165        assert_eq!(report.x[1].to_bits(), 0xbfe799e0d1f674dc);
1166        assert_eq!(report.x[2].to_bits(), 0x3fe0d5c96e019945);
1167
1168        // Determinism: a second run is bit-identical.
1169        let again = solve_trf_with(
1170            &problem,
1171            &SolveOptions::default(),
1172            TrustRegionSolve::OwnedGaussianFirstTie,
1173        )
1174        .unwrap();
1175        for i in 0..3 {
1176            assert_eq!(report.x[i].to_bits(), again.x[i].to_bits());
1177        }
1178    }
1179
1180    /// Reference Jacobian for the covariance/trace primitives.
1181    fn covariance_fixture_jacobian() -> DMatrix<f64> {
1182        // J (m=5, n=2): a linear-fit design matrix [1, t].
1183        DMatrix::from_row_slice(5, 2, &[1.0, 0.0, 1.0, 1.0, 1.0, 2.0, 1.0, 3.0, 1.0, 4.0])
1184    }
1185
1186    #[test]
1187    fn hessian_trace_matches_numpy() {
1188        // numpy: trace((J.T @ J)) == 35.0 for the fixture Jacobian.
1189        let trace = hessian_trace(&covariance_fixture_jacobian());
1190        assert!((trace - 35.0).abs() < 1e-12, "trace {trace}");
1191    }
1192
1193    #[test]
1194    fn normal_covariance_matches_numpy_pcov() {
1195        // numpy: inv(J.T @ J) for the fixture Jacobian.
1196        let inv = normal_covariance(&covariance_fixture_jacobian(), 1.0).unwrap();
1197        let expected = [[0.6000000000000001, -0.2], [-0.2, 0.1]];
1198        for i in 0..2 {
1199            for j in 0..2 {
1200                assert!(
1201                    (inv[(i, j)] - expected[i][j]).abs() < 1e-12,
1202                    "inv[{i}][{j}] = {}",
1203                    inv[(i, j)]
1204                );
1205            }
1206        }
1207
1208        // With the post-fit reduced chi-square scale s_sq = SSR/(m-n).
1209        let s_sq = 0.085 / 3.0;
1210        let cov = normal_covariance(&covariance_fixture_jacobian(), s_sq).unwrap();
1211        let expected_cov = [
1212            [0.017000000000000005, -0.005666666666666667],
1213            [-0.005666666666666667, 0.0028333333333333335],
1214        ];
1215        for i in 0..2 {
1216            for j in 0..2 {
1217                assert!(
1218                    (cov[(i, j)] - expected_cov[i][j]).abs() < 1e-12,
1219                    "cov[{i}][{j}] = {}",
1220                    cov[(i, j)]
1221                );
1222            }
1223        }
1224    }
1225
1226    #[test]
1227    fn normal_covariance_rejects_underdetermined_and_negative_scale() {
1228        let wide = DMatrix::from_row_slice(2, 3, &[1.0, 0.0, 1.0, 0.0, 1.0, 1.0]);
1229        assert!(matches!(
1230            normal_covariance(&wide, 1.0),
1231            Err(SolveError::InvalidInput {
1232                field: "jacobian",
1233                ..
1234            })
1235        ));
1236        assert!(matches!(
1237            normal_covariance(&covariance_fixture_jacobian(), -1.0),
1238            Err(SolveError::InvalidInput {
1239                field: "variance_scale",
1240                ..
1241            })
1242        ));
1243    }
1244
1245    #[test]
1246    fn normal_covariance_matches_closed_form_inverse_for_collinear_jacobian() {
1247        // A full-rank but collinear design: the second column is the first plus a
1248        // small ramp, so the two columns are nearly parallel (raised condition
1249        // number). The SVD path forms the covariance from the SVD of J directly,
1250        // not from (J^T J)^-1, so it keeps the conditioning at cond(J) rather than
1251        // squaring it. Compare against the closed-form 2x2 inverse of J^T J (the
1252        // analytic answer the SVD covariance must reproduce).
1253        let eps = 1e-2;
1254        let col1: Vec<f64> = (0..5).map(|k| 1.0 + (k as f64) * eps).collect();
1255        let mut data = Vec::with_capacity(10);
1256        for &c1 in &col1 {
1257            data.push(1.0);
1258            data.push(c1);
1259        }
1260        let jac = DMatrix::from_row_slice(5, 2, &data);
1261        let scale = 2.5;
1262        let cov = normal_covariance(&jac, scale).unwrap();
1263
1264        // Closed-form (J^T J)^-1 * scale for this moderately conditioned design.
1265        let s00 = 5.0_f64;
1266        let s01: f64 = col1.iter().sum();
1267        let s11: f64 = col1.iter().map(|c| c * c).sum();
1268        let det = s00 * s11 - s01 * s01;
1269        let inv = [[s11 / det, -s01 / det], [-s01 / det, s00 / det]];
1270        for i in 0..2 {
1271            for j in 0..2 {
1272                let expected = inv[i][j] * scale;
1273                let tol = 1e-9 * expected.abs().max(1.0);
1274                assert!(
1275                    (cov[(i, j)] - expected).abs() < tol,
1276                    "cov[{i}][{j}] = {} (expected {expected})",
1277                    cov[(i, j)]
1278                );
1279            }
1280        }
1281        // Symmetric to roundoff.
1282        assert!((cov[(0, 1)] - cov[(1, 0)]).abs() <= 1e-12 * cov[(0, 0)].abs().max(1.0));
1283    }
1284
1285    #[test]
1286    fn covariance_from_report_rejects_jacobian_dimension_mismatch() {
1287        // A report whose Jacobian shape disagrees with the residual/x lengths
1288        // (public fields let a caller build one) must be rejected, not used to
1289        // scale a covariance of the Jacobian's own dimensions.
1290        let jac = covariance_fixture_jacobian(); // 5 x 2
1291        let mismatched_rows = LeastSquaresReport {
1292            x: DVector::from_vec(vec![0.0, 0.0]),
1293            residual: DVector::from_vec(vec![0.1, -0.2, 0.15, 0.05]), // len 4 != 5 rows
1294            cost: 0.1,
1295            jacobian: jac.clone(),
1296            optimality_inf: 0.0,
1297            iterations: 0,
1298            status: Status::GradientTolerance,
1299        };
1300        assert_invalid_field(
1301            covariance_from_report(&mismatched_rows).unwrap_err(),
1302            "jacobian",
1303        );
1304
1305        let mismatched_cols = LeastSquaresReport {
1306            x: DVector::from_vec(vec![0.0, 0.0, 0.0]), // len 3 != 2 cols
1307            residual: DVector::from_vec(vec![0.1, -0.2, 0.15, 0.05, -0.1]),
1308            cost: 0.1,
1309            jacobian: jac,
1310            optimality_inf: 0.0,
1311            iterations: 0,
1312            status: Status::GradientTolerance,
1313        };
1314        assert_invalid_field(
1315            covariance_from_report(&mismatched_cols).unwrap_err(),
1316            "jacobian",
1317        );
1318    }
1319
1320    #[test]
1321    fn covariance_from_jacobian_matches_report_path_bit_for_bit() {
1322        // The Jacobian-only primitive must produce bit-identical covariance to
1323        // the report path on a matching report (same jacobian/cost, with
1324        // residual/x lengths chosen to match the Jacobian's m x n shape), and
1325        // must equal normal_covariance at the explicit reduced-chi-square scale.
1326        let jac = covariance_fixture_jacobian(); // 5 x 2
1327        let residual = DVector::from_vec(vec![0.1, -0.2, 0.15, 0.05, -0.1]);
1328        let cost = 0.5 * residual.dot(&residual);
1329        let report = LeastSquaresReport {
1330            x: DVector::from_vec(vec![0.0, 0.0]),
1331            cost,
1332            residual,
1333            jacobian: jac.clone(),
1334            optimality_inf: 0.0,
1335            iterations: 0,
1336            status: Status::GradientTolerance,
1337        };
1338
1339        let from_jac = covariance_from_jacobian(&jac, cost).unwrap();
1340        let from_report = covariance_from_report(&report).unwrap();
1341
1342        let m = jac.nrows();
1343        let n = jac.ncols();
1344        let explicit = normal_covariance(&jac, 2.0 * cost / ((m - n) as f64)).unwrap();
1345
1346        assert_eq!(from_jac.shape(), from_report.shape());
1347        for (a, (b, c)) in from_jac.iter().zip(from_report.iter().zip(explicit.iter())) {
1348            assert_eq!(a.to_bits(), b.to_bits());
1349            assert_eq!(a.to_bits(), c.to_bits());
1350        }
1351    }
1352
1353    #[test]
1354    fn covariance_from_jacobian_rejects_insufficient_dof() {
1355        // m <= n: a square (m == n) and an underdetermined (m < n) design both
1356        // have non-positive redundancy and must return the typed error, not a
1357        // panic or a NaN-laden covariance.
1358        let square = DMatrix::from_row_slice(2, 2, &[1.0, 0.0, 1.0, 1.0]);
1359        assert_invalid_field(
1360            covariance_from_jacobian(&square, 0.1).unwrap_err(),
1361            "degrees_of_freedom",
1362        );
1363
1364        let wide = DMatrix::from_row_slice(2, 3, &[1.0, 0.0, 1.0, 0.0, 1.0, 1.0]);
1365        assert_invalid_field(
1366            covariance_from_jacobian(&wide, 0.1).unwrap_err(),
1367            "degrees_of_freedom",
1368        );
1369    }
1370
1371    #[test]
1372    fn covariance_from_report_uses_reduced_chi_square() {
1373        // Build a report by hand: residual r and Jacobian J fix the scale.
1374        let jac = covariance_fixture_jacobian();
1375        let residual = DVector::from_vec(vec![0.1, -0.2, 0.15, 0.05, -0.1]);
1376        let report = LeastSquaresReport {
1377            x: DVector::from_vec(vec![0.0, 0.0]),
1378            cost: 0.5 * residual.dot(&residual),
1379            residual,
1380            jacobian: jac,
1381            optimality_inf: 0.0,
1382            iterations: 0,
1383            status: Status::GradientTolerance,
1384        };
1385        let cov = covariance_from_report(&report).unwrap();
1386        let expected_cov = [
1387            [0.017000000000000005, -0.005666666666666667],
1388            [-0.005666666666666667, 0.0028333333333333335],
1389        ];
1390        for i in 0..2 {
1391            for j in 0..2 {
1392                assert!(
1393                    (cov[(i, j)] - expected_cov[i][j]).abs() < 1e-12,
1394                    "cov[{i}][{j}] = {}",
1395                    cov[(i, j)]
1396                );
1397            }
1398        }
1399    }
1400}