Skip to main content

sidereon_core/astro/math/
least_squares.rs

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