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