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 smax = singular.iter().cloned().fold(0.0_f64, f64::max);
359    if smax == 0.0 {
360        return Err(SolveError::SingularJacobian);
361    }
362    let threshold = smax * (m.max(n) as f64) * f64::EPSILON;
363    if singular.iter().any(|&s| s <= threshold) {
364        return Err(SolveError::SingularJacobian);
365    }
366
367    // cov[i][j] = variance_scale * sum_k V[i,k] (1/sigma_k^2) V[j,k].
368    // v_t is n x n with row k = the k-th right singular vector, so V[i,k] = v_t[(k, i)].
369    let mut cov = DMatrix::zeros(n, n);
370    for i in 0..n {
371        for j in 0..n {
372            let mut acc = 0.0;
373            for k in 0..n {
374                let inv_s2 = 1.0 / (singular[k] * singular[k]);
375                acc += v_t[(k, i)] * v_t[(k, j)] * inv_s2;
376            }
377            cov[(i, j)] = acc * variance_scale;
378        }
379    }
380    validate_matrix(&cov, "covariance")?;
381    Ok(cov)
382}
383
384/// Trace of the Gauss-Newton Hessian approximation `J^T J`, i.e. the sum of the
385/// squared column norms of `jacobian`.
386///
387/// No inverse is formed: this is `sum_i ||J[:, i]||^2 == trace(J^T J)`, summed
388/// column-by-column. It equals `numpy.trace(jac.T @ jac)` to a tight tolerance
389/// (the reductions differ only in summation order).
390pub fn hessian_trace(jacobian: &DMatrix<f64>) -> f64 {
391    let n = jacobian.ncols();
392    let m = jacobian.nrows();
393    let mut trace = 0.0;
394    for i in 0..n {
395        let mut col = 0.0;
396        for r in 0..m {
397            let v = jacobian[(r, i)];
398            col += v * v;
399        }
400        trace += col;
401    }
402    trace
403}
404
405/// Fitted parameter covariance directly from a design (Jacobian) matrix and the
406/// post-fit cost, with the redundancy taken from the Jacobian's own shape.
407///
408/// This is the binding-facing primitive: it forms the covariance straight from
409/// the design matrix and the scalar cost, with no [`LeastSquaresReport`] and no
410/// fabricated residual / parameter vectors. The degrees of freedom come from the
411/// Jacobian's dimensions alone (`m = jacobian.nrows()`, `n = jacobian.ncols()`),
412/// so there are no redundant lengths to keep consistent. It scales `(J^T J)^-1`
413/// by the post-fit reduced chi-square `s_sq = 2 * cost / (m - n)` (the residual
414/// sum of squares over the redundancy), the same scale `scipy.optimize.curve_fit`
415/// applies to its `pcov`. Requires positive redundancy `m > n`; otherwise
416/// returns [`SolveError::InvalidInput`].
417pub fn covariance_from_jacobian(
418    jacobian: &DMatrix<f64>,
419    cost: f64,
420) -> Result<DMatrix<f64>, SolveError> {
421    let m = jacobian.nrows();
422    let n = jacobian.ncols();
423    if m <= n {
424        return Err(invalid_input("degrees_of_freedom", "not positive"));
425    }
426    let dof = (m - n) as f64;
427    let s_sq = validate_value(2.0 * cost / dof, "reduced_chi_square")?;
428    normal_covariance(jacobian, s_sq)
429}
430
431/// Fitted parameter covariance from a converged [`LeastSquaresReport`].
432///
433/// Convenience over [`covariance_from_jacobian`] for real-report callers: it
434/// validates that the report's Jacobian shape agrees with its residual / `x`
435/// lengths, then delegates to [`covariance_from_jacobian`] so the report path
436/// and the Jacobian path share a single reduced-chi-square scaling. Requires
437/// positive redundancy `m > n`; otherwise returns [`SolveError::InvalidInput`].
438pub fn covariance_from_report(report: &LeastSquaresReport) -> Result<DMatrix<f64>, SolveError> {
439    let m = report.residual.len();
440    let n = report.x.len();
441    // `LeastSquaresReport`'s fields are public, so the residual/x lengths that
442    // set the degrees of freedom and the Jacobian that sets the scale can be
443    // inconsistent. Reject a Jacobian whose shape does not match (m x n) rather
444    // than silently scaling a covariance of the Jacobian's dimensions by a
445    // reduced chi-square derived from unrelated vectors.
446    if report.jacobian.nrows() != m {
447        return Err(invalid_input("jacobian", "rows must match residual length"));
448    }
449    if report.jacobian.ncols() != n {
450        return Err(invalid_input(
451            "jacobian",
452            "columns must match parameter length",
453        ));
454    }
455    covariance_from_jacobian(&report.jacobian, report.cost)
456}
457
458/// A nonlinear least-squares problem: a residual closure, optional diagonal
459/// weights, and a starting point. The weighted form scales both the residual
460/// and the Jacobian rows by `sqrt(weight)`; with all weights `1` it reduces to
461/// the ordinary (unweighted) least-squares problem.
462pub struct LeastSquaresProblem<F> {
463    residual: F,
464    /// `sqrt` of the diagonal weights, or `None` for the identity weighting.
465    sqrt_weights: Option<DVector<f64>>,
466    x0: DVector<f64>,
467}
468
469impl<F> LeastSquaresProblem<F>
470where
471    F: Fn(&DVector<f64>) -> DVector<f64>,
472{
473    /// An unweighted problem (identity weighting).
474    pub fn new(residual: F, x0: DVector<f64>) -> Self {
475        Self {
476            residual,
477            sqrt_weights: None,
478            x0,
479        }
480    }
481
482    /// A problem with diagonal weights `W`; residual and Jacobian rows are
483    /// scaled by `sqrt(W)`.
484    pub fn with_weights(residual: F, x0: DVector<f64>, weights: DVector<f64>) -> Self {
485        let sqrt_weights = weights.map(f64::sqrt);
486        Self {
487            residual,
488            sqrt_weights: Some(sqrt_weights),
489            x0,
490        }
491    }
492
493    /// Weighted residual at `x`.
494    fn weighted_residual(&self, x: &DVector<f64>) -> Result<DVector<f64>, SolveError> {
495        validate_nonempty_vector(x, "parameters")?;
496        validate_vector(x, "parameters")?;
497        let r = (self.residual)(x);
498        validate_nonempty_vector(&r, "residual")?;
499        validate_vector(&r, "residual")?;
500        match &self.sqrt_weights {
501            Some(sw) => {
502                validate_nonempty_vector(sw, "weights")?;
503                validate_vector(sw, "weights")?;
504                if sw.len() != r.len() {
505                    return Err(invalid_input("weights", "length mismatch"));
506                }
507                let weighted = r.component_mul(sw);
508                validate_vector(&weighted, "weighted residual")?;
509                Ok(weighted)
510            }
511            None => Ok(r),
512        }
513    }
514}
515
516/// Trust-region (trf-style) Gauss-Newton solve.
517///
518/// At each iterate the weighted residual and its forward-difference Jacobian
519/// are formed, then a Levenberg-damped Gauss-Newton step `(J^T J + mu I) dx =
520/// -J^T r` is taken inside a trust region; the damping `mu` is grown on a
521/// rejected step and shrunk on an accepted one. The linear solve uses a dense
522/// factorization, so the converged solution is reproducible to a tight
523/// tolerance rather than to the bit.
524///
525/// Returns [`SolveError::SingularJacobian`] if the normal-equation system
526/// cannot be solved (degenerate geometry).
527///
528/// Uses the legacy [`TrustRegionSolve::NalgebraLu`] subproblem solver; for the
529/// owned deterministic factorization call [`solve_trf_with`].
530pub fn solve_trf<F>(
531    problem: &LeastSquaresProblem<F>,
532    opts: &SolveOptions,
533) -> Result<LeastSquaresReport, SolveError>
534where
535    F: Fn(&DVector<f64>) -> DVector<f64>,
536{
537    solve_trf_with(problem, opts, TrustRegionSolve::NalgebraLu)
538}
539
540/// Solve the trust-region subproblem `(J^T J + mu I) dx = rhs` with the
541/// selected factorization. The two arms produce the same algebra; they differ
542/// only in the dense solve's operation order (see [`TrustRegionSolve`]).
543fn solve_subproblem(
544    lhs: &DMatrix<f64>,
545    rhs: &DVector<f64>,
546    linear_solve: TrustRegionSolve,
547) -> Option<DVector<f64>> {
548    match linear_solve {
549        TrustRegionSolve::NalgebraLu => lhs.clone().lu().solve(rhs),
550        TrustRegionSolve::OwnedGaussianFirstTie => {
551            let n = rhs.len();
552            let a: Vec<Vec<f64>> = (0..n)
553                .map(|i| (0..n).map(|j| lhs[(i, j)]).collect())
554                .collect();
555            let b: Vec<f64> = rhs.iter().copied().collect();
556            crate::astro::math::linear::solve_linear_first_tie(&a, &b).map(DVector::from_vec)
557        }
558    }
559}
560
561/// [`solve_trf`] with an explicit choice of the trust-region subproblem solver.
562/// `NalgebraLu` reproduces the legacy SPP path; `OwnedGaussianFirstTie` is the
563/// owned deterministic kernel pinned to its own frozen-bits goldens.
564pub fn solve_trf_with<F>(
565    problem: &LeastSquaresProblem<F>,
566    opts: &SolveOptions,
567    linear_solve: TrustRegionSolve,
568) -> Result<LeastSquaresReport, SolveError>
569where
570    F: Fn(&DVector<f64>) -> DVector<f64>,
571{
572    validate_options(opts)?;
573    let n = problem.x0.len();
574
575    let mut x = problem.x0.clone();
576    validate_nonempty_vector(&x, "initial parameters")?;
577    validate_vector(&x, "initial parameters")?;
578    let mut r = problem.weighted_residual(&x)?;
579    let mut f0 = r.clone();
580    let mut jac = jacobian_2point_checked(|p| problem.weighted_residual(p), &x, &f0)?;
581    let mut nfev = 1usize; // the f0 above
582    let mut cur_cost = cost(&r)?;
583
584    // Initial Levenberg damping scaled to the Gauss-Newton normal matrix.
585    let jtj0 = jac.transpose() * &jac;
586    validate_matrix(&jtj0, "normal matrix")?;
587    let mut mu = TRF_INITIAL_DAMPING_SCALE
588        * (0..n)
589            .map(|i| jtj0[(i, i)])
590            .fold(0.0_f64, f64::max)
591            .max(1.0);
592
593    let mut iterations = 0usize;
594
595    loop {
596        let jt = jac.transpose();
597        let grad = &jt * &r;
598        validate_vector(&grad, "gradient")?;
599        let optimality_inf = validate_value(grad.amax(), "optimality")?;
600
601        if optimality_inf < opts.gtol {
602            return finish(x, r, cur_cost, jac, iterations, Status::GradientTolerance);
603        }
604        if nfev >= opts.max_nfev {
605            return finish(x, r, cur_cost, jac, iterations, Status::MaxEvaluations);
606        }
607
608        let jtj = &jt * &jac;
609        validate_matrix(&jtj, "normal matrix")?;
610
611        // Levenberg-damped Gauss-Newton subproblem.
612        let mut accepted = false;
613        for _ in 0..30 {
614            let mut lhs = jtj.clone();
615            for i in 0..n {
616                lhs[(i, i)] += mu;
617            }
618            let rhs = -&grad;
619            validate_matrix(&lhs, "subproblem matrix")?;
620            validate_vector(&rhs, "subproblem rhs")?;
621            let step = match solve_subproblem(&lhs, &rhs, linear_solve) {
622                Some(s) => s,
623                None => return Err(SolveError::SingularJacobian),
624            };
625            validate_vector(&step, "step")?;
626
627            let x_trial = &x + &step;
628            let r_trial = problem.weighted_residual(&x_trial)?;
629            nfev += 1;
630            let cost_trial = cost(&r_trial)?;
631
632            if cost_trial < cur_cost {
633                // Accept; relative-cost and relative-step stopping checks.
634                let cost_reduction = (cur_cost - cost_trial) / cur_cost.max(f64::MIN_POSITIVE);
635                let step_norm = step.norm();
636                let x_norm = x.norm();
637                let rel_step = step_norm / x_norm.max(f64::MIN_POSITIVE);
638
639                x = x_trial;
640                r = r_trial;
641                cur_cost = cost_trial;
642                f0 = r.clone();
643                jac = jacobian_2point_checked(|p| problem.weighted_residual(p), &x, &f0)?;
644                nfev += n; // FD probes for the new Jacobian
645                iterations += 1;
646                mu *= 0.5;
647                accepted = true;
648
649                if cost_reduction < opts.ftol {
650                    return finish(x, r, cur_cost, jac, iterations, Status::CostTolerance);
651                }
652                if rel_step < opts.xtol {
653                    return finish(x, r, cur_cost, jac, iterations, Status::StepTolerance);
654                }
655                break;
656            } else {
657                // Reject: grow damping and retry the subproblem.
658                mu *= 2.0;
659            }
660        }
661
662        if !accepted {
663            // Could not find an improving step within the damping sweep.
664            return finish(x, r, cur_cost, jac, iterations, Status::StepTolerance);
665        }
666    }
667}
668
669fn finish(
670    x: DVector<f64>,
671    residual: DVector<f64>,
672    cost_value: f64,
673    jacobian: DMatrix<f64>,
674    iterations: usize,
675    status: Status,
676) -> Result<LeastSquaresReport, SolveError> {
677    validate_nonempty_vector(&x, "solution")?;
678    validate_vector(&x, "solution")?;
679    validate_nonempty_vector(&residual, "residual")?;
680    validate_vector(&residual, "residual")?;
681    validate_value(cost_value, "cost")?;
682    validate_matrix(&jacobian, "jacobian")?;
683    let optimality_inf = validate_value((jacobian.transpose() * &residual).amax(), "optimality")?;
684    Ok(LeastSquaresReport {
685        x,
686        residual,
687        cost: cost_value,
688        jacobian,
689        optimality_inf,
690        iterations,
691        status,
692    })
693}
694
695fn validate_value(value: f64, field: &'static str) -> Result<f64, SolveError> {
696    crate::validate::finite(value, field).map_err(map_field_error)
697}
698
699fn validate_options(opts: &SolveOptions) -> Result<(), SolveError> {
700    crate::validate::positive_step(opts.gtol, "gtol").map_err(map_field_error)?;
701    crate::validate::positive_step(opts.ftol, "ftol").map_err(map_field_error)?;
702    crate::validate::positive_step(opts.xtol, "xtol").map_err(map_field_error)?;
703    if opts.max_nfev == 0 {
704        return Err(invalid_input("max_nfev", "not positive"));
705    }
706    Ok(())
707}
708
709fn validate_nonempty_vector(vector: &DVector<f64>, field: &'static str) -> Result<(), SolveError> {
710    if vector.is_empty() {
711        Err(invalid_input(field, "empty"))
712    } else {
713        Ok(())
714    }
715}
716
717fn validate_vector(vector: &DVector<f64>, field: &'static str) -> Result<(), SolveError> {
718    crate::validate::finite_slice(vector.as_slice(), field).map_err(map_field_error)
719}
720
721fn validate_matrix(matrix: &DMatrix<f64>, field: &'static str) -> Result<(), SolveError> {
722    crate::validate::finite_slice(matrix.as_slice(), field).map_err(map_field_error)
723}
724
725fn map_field_error(error: crate::validate::FieldError) -> SolveError {
726    invalid_input(error.field(), error.reason())
727}
728
729fn invalid_input(field: &'static str, reason: &'static str) -> SolveError {
730    SolveError::InvalidInput { field, reason }
731}
732
733#[cfg(test)]
734mod tests {
735    use super::*;
736
737    #[test]
738    fn fd_rel_step_is_sqrt_eps() {
739        assert_eq!(FD_REL_STEP_2POINT, (2.0_f64.powi(-52)).sqrt());
740        assert_eq!(FD_REL_STEP_2POINT, 2.0_f64.powi(-26));
741    }
742
743    #[test]
744    fn fd_step_sign_convention() {
745        let x0 = DVector::from_vec(vec![5.0, -2.0, 0.0]);
746        let steps = fd_steps(&x0, FD_REL_STEP_2POINT).unwrap();
747        assert_eq!(steps[0].sign_x0, 1.0);
748        assert_eq!(steps[1].sign_x0, -1.0);
749        assert_eq!(steps[2].sign_x0, 1.0); // x == 0 -> +1
750    }
751
752    #[test]
753    fn fd_steps_rejects_zero_relative_step() {
754        let x0 = DVector::from_vec(vec![1.0]);
755        assert_invalid_field(fd_steps(&x0, 0.0).unwrap_err(), "rel_step");
756    }
757
758    #[test]
759    fn fd_steps_rejects_nonfinite_parameters() {
760        let x0 = DVector::from_vec(vec![1.0, f64::NAN]);
761        assert_invalid_field(fd_steps(&x0, FD_REL_STEP_2POINT).unwrap_err(), "parameters");
762    }
763
764    #[test]
765    fn jacobian_rejects_residual_length_mismatch() {
766        let x0 = DVector::from_vec(vec![1.0, 2.0]);
767        let f0 = DVector::from_vec(vec![1.0, 2.0]);
768        let residual = |_: &DVector<f64>| DVector::from_vec(vec![1.0]);
769        assert_invalid_field(jacobian_2point(residual, &x0, &f0).unwrap_err(), "residual");
770    }
771
772    #[test]
773    fn cost_rejects_nonfinite_residual() {
774        assert_invalid_field(
775            cost(&DVector::from_vec(vec![1.0, f64::INFINITY])).unwrap_err(),
776            "residual",
777        );
778    }
779
780    #[test]
781    fn exp_fit_converges() {
782        // a*exp(b*t) + c with a known minimum near the generated data.
783        let t = vec![0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0];
784        let y = vec![
785            3.0123, 2.2083, 1.6889, 1.3713, 1.0903, 0.9302, 0.8104, 0.6303,
786        ];
787        let tt = t.clone();
788        let yy = y.clone();
789        let residual = move |p: &DVector<f64>| {
790            let (a, b, c) = (p[0], p[1], p[2]);
791            DVector::from_iterator(
792                tt.len(),
793                tt.iter()
794                    .zip(&yy)
795                    .map(|(&tk, &yk)| a * (b * tk).exp() + c - yk),
796            )
797        };
798        let problem = LeastSquaresProblem::new(residual, DVector::from_vec(vec![5.0, -2.0, 2.0]));
799        let report = solve_trf(&problem, &SolveOptions::default()).unwrap();
800        assert!(report.cost < 1.0, "cost did not reduce: {}", report.cost);
801    }
802
803    #[test]
804    fn solve_trf_rejects_nonfinite_initial_residual() {
805        fn residual(_: &DVector<f64>) -> DVector<f64> {
806            DVector::from_element(1, f64::NAN)
807        }
808        let problem = LeastSquaresProblem::new(residual, DVector::from_element(1, 0.0));
809        assert_invalid_field(
810            solve_trf(&problem, &SolveOptions::default()).unwrap_err(),
811            "residual",
812        );
813    }
814
815    #[test]
816    fn solve_trf_rejects_nonfinite_initial_cost() {
817        fn residual(_: &DVector<f64>) -> DVector<f64> {
818            DVector::from_element(1, f64::MAX)
819        }
820        let problem = LeastSquaresProblem::new(residual, DVector::from_element(1, 0.0));
821        assert_invalid_field(
822            solve_trf(&problem, &SolveOptions::default()).unwrap_err(),
823            "cost",
824        );
825    }
826
827    #[test]
828    fn solve_trf_rejects_nonfinite_trial_residual_instead_of_converging() {
829        use std::cell::Cell;
830
831        let calls = Cell::new(0usize);
832        let residual = move |p: &DVector<f64>| {
833            let call = calls.get();
834            calls.set(call + 1);
835            if call >= 2 {
836                DVector::from_element(1, f64::NAN)
837            } else {
838                DVector::from_element(1, p[0])
839            }
840        };
841        let problem = LeastSquaresProblem::new(residual, DVector::from_element(1, 1.0));
842        assert_invalid_field(
843            solve_trf(&problem, &SolveOptions::default()).unwrap_err(),
844            "residual",
845        );
846    }
847
848    #[test]
849    fn solve_trf_rejects_invalid_options() {
850        fn residual(p: &DVector<f64>) -> DVector<f64> {
851            DVector::from_element(1, p[0])
852        }
853        let problem = LeastSquaresProblem::new(residual, DVector::from_element(1, 1.0));
854        let opts = SolveOptions {
855            gtol: f64::NAN,
856            ..SolveOptions::default()
857        };
858        assert_invalid_field(solve_trf(&problem, &opts).unwrap_err(), "gtol");
859
860        let opts = SolveOptions {
861            max_nfev: 0,
862            ..SolveOptions::default()
863        };
864        assert_invalid_field(solve_trf(&problem, &opts).unwrap_err(), "max_nfev");
865    }
866
867    #[test]
868    fn solve_trf_rejects_weight_residual_dimension_mismatch() {
869        fn residual(_: &DVector<f64>) -> DVector<f64> {
870            DVector::from_vec(vec![1.0, 2.0])
871        }
872        let problem = LeastSquaresProblem::with_weights(
873            residual,
874            DVector::from_element(1, 0.0),
875            DVector::from_vec(vec![1.0]),
876        );
877        assert_invalid_field(
878            solve_trf(&problem, &SolveOptions::default()).unwrap_err(),
879            "weights",
880        );
881    }
882
883    fn assert_invalid_field(error: SolveError, expected: &'static str) {
884        match error {
885            SolveError::InvalidInput { field, .. } => assert_eq!(field, expected),
886            other => panic!("expected invalid input for {expected}, got {other:?}"),
887        }
888    }
889
890    /// The exp-fit residual used by the owned-solver tests.
891    fn exp_fit_problem() -> LeastSquaresProblem<impl Fn(&DVector<f64>) -> DVector<f64>> {
892        let t = [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0];
893        let y = [
894            3.0123, 2.2083, 1.6889, 1.3713, 1.0903, 0.9302, 0.8104, 0.6303,
895        ];
896        let residual = move |p: &DVector<f64>| {
897            let (a, b, c) = (p[0], p[1], p[2]);
898            DVector::from_iterator(
899                t.len(),
900                t.iter()
901                    .zip(&y)
902                    .map(|(&tk, &yk)| a * (b * tk).exp() + c - yk),
903            )
904        };
905        LeastSquaresProblem::new(residual, DVector::from_vec(vec![5.0, -2.0, 2.0]))
906    }
907
908    /// The owned deterministic subproblem solver converges on the exp-fit
909    /// problem and reproduces its solution bit-for-bit run to run. The pinned
910    /// bits are the owned kernel's own frozen-bits golden (a different
911    /// factorization than the legacy nalgebra path, so its own value). The
912    /// owned kernel owns only the subproblem factorization; the surrounding
913    /// `J^T J`/`J^T r`/norm reductions are shared nalgebra dense algebra, so
914    /// these bits are this build's reproducible output (the run-to-run check
915    /// below), not a cross-platform constant.
916    #[test]
917    fn owned_trf_converges_to_frozen_bits() {
918        let problem = exp_fit_problem();
919        let report = solve_trf_with(
920            &problem,
921            &SolveOptions::default(),
922            TrustRegionSolve::OwnedGaussianFirstTie,
923        )
924        .unwrap();
925        assert!(
926            report.cost < 1.0,
927            "owned cost did not reduce: {}",
928            report.cost
929        );
930        assert_eq!(report.x[0].to_bits(), 0x4003c3674cdfadef);
931        assert_eq!(report.x[1].to_bits(), 0xbfe799e0d1929220);
932        assert_eq!(report.x[2].to_bits(), 0x3fe0d5c96d9d3b35);
933
934        // Determinism: a second run is bit-identical.
935        let again = solve_trf_with(
936            &problem,
937            &SolveOptions::default(),
938            TrustRegionSolve::OwnedGaussianFirstTie,
939        )
940        .unwrap();
941        for i in 0..3 {
942            assert_eq!(report.x[i].to_bits(), again.x[i].to_bits());
943        }
944    }
945
946    /// Reference Jacobian for the covariance/trace primitives.
947    fn covariance_fixture_jacobian() -> DMatrix<f64> {
948        // J (m=5, n=2): a linear-fit design matrix [1, t].
949        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])
950    }
951
952    #[test]
953    fn hessian_trace_matches_numpy() {
954        // numpy: trace((J.T @ J)) == 35.0 for the fixture Jacobian.
955        let trace = hessian_trace(&covariance_fixture_jacobian());
956        assert!((trace - 35.0).abs() < 1e-12, "trace {trace}");
957    }
958
959    #[test]
960    fn normal_covariance_matches_numpy_pcov() {
961        // numpy: inv(J.T @ J) for the fixture Jacobian.
962        let inv = normal_covariance(&covariance_fixture_jacobian(), 1.0).unwrap();
963        let expected = [[0.6000000000000001, -0.2], [-0.2, 0.1]];
964        for i in 0..2 {
965            for j in 0..2 {
966                assert!(
967                    (inv[(i, j)] - expected[i][j]).abs() < 1e-12,
968                    "inv[{i}][{j}] = {}",
969                    inv[(i, j)]
970                );
971            }
972        }
973
974        // With the post-fit reduced chi-square scale s_sq = SSR/(m-n).
975        let s_sq = 0.085 / 3.0;
976        let cov = normal_covariance(&covariance_fixture_jacobian(), s_sq).unwrap();
977        let expected_cov = [
978            [0.017000000000000005, -0.005666666666666667],
979            [-0.005666666666666667, 0.0028333333333333335],
980        ];
981        for i in 0..2 {
982            for j in 0..2 {
983                assert!(
984                    (cov[(i, j)] - expected_cov[i][j]).abs() < 1e-12,
985                    "cov[{i}][{j}] = {}",
986                    cov[(i, j)]
987                );
988            }
989        }
990    }
991
992    #[test]
993    fn normal_covariance_rejects_underdetermined_and_negative_scale() {
994        let wide = DMatrix::from_row_slice(2, 3, &[1.0, 0.0, 1.0, 0.0, 1.0, 1.0]);
995        assert!(matches!(
996            normal_covariance(&wide, 1.0),
997            Err(SolveError::InvalidInput {
998                field: "jacobian",
999                ..
1000            })
1001        ));
1002        assert!(matches!(
1003            normal_covariance(&covariance_fixture_jacobian(), -1.0),
1004            Err(SolveError::InvalidInput {
1005                field: "variance_scale",
1006                ..
1007            })
1008        ));
1009    }
1010
1011    #[test]
1012    fn normal_covariance_matches_closed_form_inverse_for_collinear_jacobian() {
1013        // A full-rank but collinear design: the second column is the first plus a
1014        // small ramp, so the two columns are nearly parallel (raised condition
1015        // number). The SVD path forms the covariance from the SVD of J directly,
1016        // not from (J^T J)^-1, so it keeps the conditioning at cond(J) rather than
1017        // squaring it. Compare against the closed-form 2x2 inverse of J^T J (the
1018        // analytic answer the SVD covariance must reproduce).
1019        let eps = 1e-2;
1020        let col1: Vec<f64> = (0..5).map(|k| 1.0 + (k as f64) * eps).collect();
1021        let mut data = Vec::with_capacity(10);
1022        for &c1 in &col1 {
1023            data.push(1.0);
1024            data.push(c1);
1025        }
1026        let jac = DMatrix::from_row_slice(5, 2, &data);
1027        let scale = 2.5;
1028        let cov = normal_covariance(&jac, scale).unwrap();
1029
1030        // Closed-form (J^T J)^-1 * scale for this moderately conditioned design.
1031        let s00 = 5.0_f64;
1032        let s01: f64 = col1.iter().sum();
1033        let s11: f64 = col1.iter().map(|c| c * c).sum();
1034        let det = s00 * s11 - s01 * s01;
1035        let inv = [[s11 / det, -s01 / det], [-s01 / det, s00 / det]];
1036        for i in 0..2 {
1037            for j in 0..2 {
1038                let expected = inv[i][j] * scale;
1039                let tol = 1e-9 * expected.abs().max(1.0);
1040                assert!(
1041                    (cov[(i, j)] - expected).abs() < tol,
1042                    "cov[{i}][{j}] = {} (expected {expected})",
1043                    cov[(i, j)]
1044                );
1045            }
1046        }
1047        // Symmetric to roundoff.
1048        assert!((cov[(0, 1)] - cov[(1, 0)]).abs() <= 1e-12 * cov[(0, 0)].abs().max(1.0));
1049    }
1050
1051    #[test]
1052    fn covariance_from_report_rejects_jacobian_dimension_mismatch() {
1053        // A report whose Jacobian shape disagrees with the residual/x lengths
1054        // (public fields let a caller build one) must be rejected, not used to
1055        // scale a covariance of the Jacobian's own dimensions.
1056        let jac = covariance_fixture_jacobian(); // 5 x 2
1057        let mismatched_rows = LeastSquaresReport {
1058            x: DVector::from_vec(vec![0.0, 0.0]),
1059            residual: DVector::from_vec(vec![0.1, -0.2, 0.15, 0.05]), // len 4 != 5 rows
1060            cost: 0.1,
1061            jacobian: jac.clone(),
1062            optimality_inf: 0.0,
1063            iterations: 0,
1064            status: Status::GradientTolerance,
1065        };
1066        assert_invalid_field(
1067            covariance_from_report(&mismatched_rows).unwrap_err(),
1068            "jacobian",
1069        );
1070
1071        let mismatched_cols = LeastSquaresReport {
1072            x: DVector::from_vec(vec![0.0, 0.0, 0.0]), // len 3 != 2 cols
1073            residual: DVector::from_vec(vec![0.1, -0.2, 0.15, 0.05, -0.1]),
1074            cost: 0.1,
1075            jacobian: jac,
1076            optimality_inf: 0.0,
1077            iterations: 0,
1078            status: Status::GradientTolerance,
1079        };
1080        assert_invalid_field(
1081            covariance_from_report(&mismatched_cols).unwrap_err(),
1082            "jacobian",
1083        );
1084    }
1085
1086    #[test]
1087    fn covariance_from_jacobian_matches_report_path_bit_for_bit() {
1088        // The Jacobian-only primitive must produce bit-identical covariance to
1089        // the report path on a matching report (same jacobian/cost, with
1090        // residual/x lengths chosen to match the Jacobian's m x n shape), and
1091        // must equal normal_covariance at the explicit reduced-chi-square scale.
1092        let jac = covariance_fixture_jacobian(); // 5 x 2
1093        let residual = DVector::from_vec(vec![0.1, -0.2, 0.15, 0.05, -0.1]);
1094        let cost = 0.5 * residual.dot(&residual);
1095        let report = LeastSquaresReport {
1096            x: DVector::from_vec(vec![0.0, 0.0]),
1097            cost,
1098            residual,
1099            jacobian: jac.clone(),
1100            optimality_inf: 0.0,
1101            iterations: 0,
1102            status: Status::GradientTolerance,
1103        };
1104
1105        let from_jac = covariance_from_jacobian(&jac, cost).unwrap();
1106        let from_report = covariance_from_report(&report).unwrap();
1107
1108        let m = jac.nrows();
1109        let n = jac.ncols();
1110        let explicit = normal_covariance(&jac, 2.0 * cost / ((m - n) as f64)).unwrap();
1111
1112        assert_eq!(from_jac.shape(), from_report.shape());
1113        for (a, (b, c)) in from_jac.iter().zip(from_report.iter().zip(explicit.iter())) {
1114            assert_eq!(a.to_bits(), b.to_bits());
1115            assert_eq!(a.to_bits(), c.to_bits());
1116        }
1117    }
1118
1119    #[test]
1120    fn covariance_from_jacobian_rejects_insufficient_dof() {
1121        // m <= n: a square (m == n) and an underdetermined (m < n) design both
1122        // have non-positive redundancy and must return the typed error, not a
1123        // panic or a NaN-laden covariance.
1124        let square = DMatrix::from_row_slice(2, 2, &[1.0, 0.0, 1.0, 1.0]);
1125        assert_invalid_field(
1126            covariance_from_jacobian(&square, 0.1).unwrap_err(),
1127            "degrees_of_freedom",
1128        );
1129
1130        let wide = DMatrix::from_row_slice(2, 3, &[1.0, 0.0, 1.0, 0.0, 1.0, 1.0]);
1131        assert_invalid_field(
1132            covariance_from_jacobian(&wide, 0.1).unwrap_err(),
1133            "degrees_of_freedom",
1134        );
1135    }
1136
1137    #[test]
1138    fn covariance_from_report_uses_reduced_chi_square() {
1139        // Build a report by hand: residual r and Jacobian J fix the scale.
1140        let jac = covariance_fixture_jacobian();
1141        let residual = DVector::from_vec(vec![0.1, -0.2, 0.15, 0.05, -0.1]);
1142        let report = LeastSquaresReport {
1143            x: DVector::from_vec(vec![0.0, 0.0]),
1144            cost: 0.5 * residual.dot(&residual),
1145            residual,
1146            jacobian: jac,
1147            optimality_inf: 0.0,
1148            iterations: 0,
1149            status: Status::GradientTolerance,
1150        };
1151        let cov = covariance_from_report(&report).unwrap();
1152        let expected_cov = [
1153            [0.017000000000000005, -0.005666666666666667],
1154            [-0.005666666666666667, 0.0028333333333333335],
1155        ];
1156        for i in 0..2 {
1157            for j in 0..2 {
1158                assert!(
1159                    (cov[(i, j)] - expected_cov[i][j]).abs() < 1e-12,
1160                    "cov[{i}][{j}] = {}",
1161                    cov[(i, j)]
1162                );
1163            }
1164        }
1165    }
1166}