use nalgebra::{DMatrix, DVector};
pub const FD_REL_STEP_2POINT: f64 = 1.4901161193847656e-8;
const TRF_DEFAULT_GTOL: f64 = 1e-10;
const TRF_DEFAULT_FTOL: f64 = 1e-8;
const TRF_DEFAULT_XTOL: f64 = 1e-8;
const TRF_DEFAULT_MAX_NFEV: usize = 300;
const TRF_INITIAL_DAMPING_SCALE: f64 = 1e-3;
#[derive(Debug, Clone, PartialEq)]
pub struct FdStep {
pub param_index: usize,
pub sign_x0: f64,
pub h: f64,
pub dx: f64,
pub x_perturbed: DVector<f64>,
}
pub fn fd_steps(x0: &DVector<f64>, rel_step: f64) -> Result<Vec<FdStep>, SolveError> {
let rel_step = crate::validate::positive_step(rel_step, "rel_step").map_err(map_field_error)?;
fd_steps_checked(x0, rel_step)
}
fn fd_steps_checked(x0: &DVector<f64>, rel_step: f64) -> Result<Vec<FdStep>, SolveError> {
validate_nonempty_vector(x0, "parameters")?;
validate_vector(x0, "parameters")?;
let steps = fd_steps_unchecked(x0, rel_step);
for step in &steps {
validate_value(step.h, "fd_step")?;
validate_value(step.dx, "fd_step")?;
if step.dx == 0.0 {
return Err(invalid_input("fd_step", "zero"));
}
validate_vector(&step.x_perturbed, "perturbed parameters")?;
}
Ok(steps)
}
fn fd_steps_unchecked(x0: &DVector<f64>, rel_step: f64) -> Vec<FdStep> {
(0..x0.len())
.map(|i| {
let xi = x0[i];
let sign_x0 = if xi >= 0.0 { 1.0 } else { -1.0 };
let h = rel_step * sign_x0 * xi.abs().max(1.0);
let mut x_perturbed = x0.clone();
x_perturbed[i] = xi + h;
let dx = x_perturbed[i] - xi;
FdStep {
param_index: i,
sign_x0,
h,
dx,
x_perturbed,
}
})
.collect()
}
pub fn jacobian_2point<F>(
residual: F,
x0: &DVector<f64>,
f0: &DVector<f64>,
) -> Result<DMatrix<f64>, SolveError>
where
F: Fn(&DVector<f64>) -> DVector<f64>,
{
jacobian_2point_checked(|x| Ok(residual(x)), x0, f0)
}
fn jacobian_2point_checked<F>(
residual: F,
x0: &DVector<f64>,
f0: &DVector<f64>,
) -> Result<DMatrix<f64>, SolveError>
where
F: Fn(&DVector<f64>) -> Result<DVector<f64>, SolveError>,
{
validate_nonempty_vector(x0, "parameters")?;
validate_vector(x0, "parameters")?;
validate_nonempty_vector(f0, "residual")?;
validate_vector(f0, "residual")?;
let m = f0.len();
let n = x0.len();
let steps = fd_steps_checked(x0, FD_REL_STEP_2POINT)?;
let mut jac = DMatrix::zeros(m, n);
for step in &steps {
let f1 = residual(&step.x_perturbed)?;
validate_nonempty_vector(&f1, "residual")?;
validate_vector(&f1, "residual")?;
if f1.len() != m {
return Err(invalid_input("residual", "length mismatch"));
}
let i = step.param_index;
for row in 0..m {
jac[(row, i)] = (f1[row] - f0[row]) / step.dx;
}
}
validate_matrix(&jac, "jacobian")?;
Ok(jac)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
GradientTolerance,
CostTolerance,
StepTolerance,
MaxEvaluations,
}
#[derive(Debug, Clone, Copy)]
pub struct SolveOptions {
pub gtol: f64,
pub ftol: f64,
pub xtol: f64,
pub max_nfev: usize,
}
impl Default for SolveOptions {
fn default() -> Self {
Self {
gtol: TRF_DEFAULT_GTOL,
ftol: TRF_DEFAULT_FTOL,
xtol: TRF_DEFAULT_XTOL,
max_nfev: TRF_DEFAULT_MAX_NFEV,
}
}
}
#[derive(Debug, Clone)]
pub struct LeastSquaresReport {
pub x: DVector<f64>,
pub residual: DVector<f64>,
pub cost: f64,
pub jacobian: DMatrix<f64>,
pub optimality_inf: f64,
pub iterations: usize,
pub status: Status,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum TrustRegionSolve {
#[default]
NalgebraLu,
OwnedGaussianFirstTie,
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum SolveError {
#[error("singular or rank-deficient Jacobian: no usable descent direction")]
SingularJacobian,
#[error("invalid least-squares {field}: {reason}")]
InvalidInput {
field: &'static str,
reason: &'static str,
},
}
pub fn cost(residual: &DVector<f64>) -> Result<f64, SolveError> {
validate_nonempty_vector(residual, "residual")?;
validate_vector(residual, "residual")?;
validate_value(0.5 * residual.dot(residual), "cost")
}
pub fn normal_covariance(
jacobian: &DMatrix<f64>,
variance_scale: f64,
) -> Result<DMatrix<f64>, SolveError> {
validate_matrix(jacobian, "jacobian")?;
let m = jacobian.nrows();
let n = jacobian.ncols();
if n == 0 || m == 0 {
return Err(invalid_input("jacobian", "empty"));
}
if m < n {
return Err(invalid_input("jacobian", "fewer rows than columns"));
}
crate::validate::finite_nonneg(variance_scale, "variance_scale").map_err(map_field_error)?;
let svd = jacobian.clone().svd(false, true);
let v_t = svd.v_t.ok_or(SolveError::SingularJacobian)?;
let singular = svd.singular_values;
let smax = singular.iter().cloned().fold(0.0_f64, f64::max);
if smax == 0.0 {
return Err(SolveError::SingularJacobian);
}
let threshold = smax * (m.max(n) as f64) * f64::EPSILON;
if singular.iter().any(|&s| s <= threshold) {
return Err(SolveError::SingularJacobian);
}
let mut cov = DMatrix::zeros(n, n);
for i in 0..n {
for j in 0..n {
let mut acc = 0.0;
for k in 0..n {
let inv_s2 = 1.0 / (singular[k] * singular[k]);
acc += v_t[(k, i)] * v_t[(k, j)] * inv_s2;
}
cov[(i, j)] = acc * variance_scale;
}
}
validate_matrix(&cov, "covariance")?;
Ok(cov)
}
pub fn hessian_trace(jacobian: &DMatrix<f64>) -> f64 {
let n = jacobian.ncols();
let m = jacobian.nrows();
let mut trace = 0.0;
for i in 0..n {
let mut col = 0.0;
for r in 0..m {
let v = jacobian[(r, i)];
col += v * v;
}
trace += col;
}
trace
}
pub fn covariance_from_jacobian(
jacobian: &DMatrix<f64>,
cost: f64,
) -> Result<DMatrix<f64>, SolveError> {
let m = jacobian.nrows();
let n = jacobian.ncols();
if m <= n {
return Err(invalid_input("degrees_of_freedom", "not positive"));
}
let dof = (m - n) as f64;
let s_sq = validate_value(2.0 * cost / dof, "reduced_chi_square")?;
normal_covariance(jacobian, s_sq)
}
pub fn covariance_from_report(report: &LeastSquaresReport) -> Result<DMatrix<f64>, SolveError> {
let m = report.residual.len();
let n = report.x.len();
if report.jacobian.nrows() != m {
return Err(invalid_input("jacobian", "rows must match residual length"));
}
if report.jacobian.ncols() != n {
return Err(invalid_input(
"jacobian",
"columns must match parameter length",
));
}
covariance_from_jacobian(&report.jacobian, report.cost)
}
pub struct LeastSquaresProblem<F> {
residual: F,
sqrt_weights: Option<DVector<f64>>,
x0: DVector<f64>,
}
impl<F> LeastSquaresProblem<F>
where
F: Fn(&DVector<f64>) -> DVector<f64>,
{
pub fn new(residual: F, x0: DVector<f64>) -> Self {
Self {
residual,
sqrt_weights: None,
x0,
}
}
pub fn with_weights(residual: F, x0: DVector<f64>, weights: DVector<f64>) -> Self {
let sqrt_weights = weights.map(f64::sqrt);
Self {
residual,
sqrt_weights: Some(sqrt_weights),
x0,
}
}
fn weighted_residual(&self, x: &DVector<f64>) -> Result<DVector<f64>, SolveError> {
validate_nonempty_vector(x, "parameters")?;
validate_vector(x, "parameters")?;
let r = (self.residual)(x);
validate_nonempty_vector(&r, "residual")?;
validate_vector(&r, "residual")?;
match &self.sqrt_weights {
Some(sw) => {
validate_nonempty_vector(sw, "weights")?;
validate_vector(sw, "weights")?;
if sw.len() != r.len() {
return Err(invalid_input("weights", "length mismatch"));
}
let weighted = r.component_mul(sw);
validate_vector(&weighted, "weighted residual")?;
Ok(weighted)
}
None => Ok(r),
}
}
}
pub fn solve_trf<F>(
problem: &LeastSquaresProblem<F>,
opts: &SolveOptions,
) -> Result<LeastSquaresReport, SolveError>
where
F: Fn(&DVector<f64>) -> DVector<f64>,
{
solve_trf_with(problem, opts, TrustRegionSolve::NalgebraLu)
}
fn solve_subproblem(
lhs: &DMatrix<f64>,
rhs: &DVector<f64>,
linear_solve: TrustRegionSolve,
) -> Option<DVector<f64>> {
match linear_solve {
TrustRegionSolve::NalgebraLu => lhs.clone().lu().solve(rhs),
TrustRegionSolve::OwnedGaussianFirstTie => {
let n = rhs.len();
let a: Vec<Vec<f64>> = (0..n)
.map(|i| (0..n).map(|j| lhs[(i, j)]).collect())
.collect();
let b: Vec<f64> = rhs.iter().copied().collect();
crate::astro::math::linear::solve_linear_first_tie(&a, &b).map(DVector::from_vec)
}
}
}
pub fn solve_trf_with<F>(
problem: &LeastSquaresProblem<F>,
opts: &SolveOptions,
linear_solve: TrustRegionSolve,
) -> Result<LeastSquaresReport, SolveError>
where
F: Fn(&DVector<f64>) -> DVector<f64>,
{
validate_options(opts)?;
let n = problem.x0.len();
let mut x = problem.x0.clone();
validate_nonempty_vector(&x, "initial parameters")?;
validate_vector(&x, "initial parameters")?;
let mut r = problem.weighted_residual(&x)?;
let mut f0 = r.clone();
let mut jac = jacobian_2point_checked(|p| problem.weighted_residual(p), &x, &f0)?;
let mut nfev = 1usize; let mut cur_cost = cost(&r)?;
let jtj0 = jac.transpose() * &jac;
validate_matrix(&jtj0, "normal matrix")?;
let mut mu = TRF_INITIAL_DAMPING_SCALE
* (0..n)
.map(|i| jtj0[(i, i)])
.fold(0.0_f64, f64::max)
.max(1.0);
let mut iterations = 0usize;
loop {
let jt = jac.transpose();
let grad = &jt * &r;
validate_vector(&grad, "gradient")?;
let optimality_inf = validate_value(grad.amax(), "optimality")?;
if optimality_inf < opts.gtol {
return finish(x, r, cur_cost, jac, iterations, Status::GradientTolerance);
}
if nfev >= opts.max_nfev {
return finish(x, r, cur_cost, jac, iterations, Status::MaxEvaluations);
}
let jtj = &jt * &jac;
validate_matrix(&jtj, "normal matrix")?;
let mut accepted = false;
for _ in 0..30 {
let mut lhs = jtj.clone();
for i in 0..n {
lhs[(i, i)] += mu;
}
let rhs = -&grad;
validate_matrix(&lhs, "subproblem matrix")?;
validate_vector(&rhs, "subproblem rhs")?;
let step = match solve_subproblem(&lhs, &rhs, linear_solve) {
Some(s) => s,
None => return Err(SolveError::SingularJacobian),
};
validate_vector(&step, "step")?;
let x_trial = &x + &step;
let r_trial = problem.weighted_residual(&x_trial)?;
nfev += 1;
let cost_trial = cost(&r_trial)?;
if cost_trial < cur_cost {
let cost_reduction = (cur_cost - cost_trial) / cur_cost.max(f64::MIN_POSITIVE);
let step_norm = step.norm();
let x_norm = x.norm();
let rel_step = step_norm / x_norm.max(f64::MIN_POSITIVE);
x = x_trial;
r = r_trial;
cur_cost = cost_trial;
f0 = r.clone();
jac = jacobian_2point_checked(|p| problem.weighted_residual(p), &x, &f0)?;
nfev += n; iterations += 1;
mu *= 0.5;
accepted = true;
if cost_reduction < opts.ftol {
return finish(x, r, cur_cost, jac, iterations, Status::CostTolerance);
}
if rel_step < opts.xtol {
return finish(x, r, cur_cost, jac, iterations, Status::StepTolerance);
}
break;
} else {
mu *= 2.0;
}
}
if !accepted {
return finish(x, r, cur_cost, jac, iterations, Status::StepTolerance);
}
}
}
fn finish(
x: DVector<f64>,
residual: DVector<f64>,
cost_value: f64,
jacobian: DMatrix<f64>,
iterations: usize,
status: Status,
) -> Result<LeastSquaresReport, SolveError> {
validate_nonempty_vector(&x, "solution")?;
validate_vector(&x, "solution")?;
validate_nonempty_vector(&residual, "residual")?;
validate_vector(&residual, "residual")?;
validate_value(cost_value, "cost")?;
validate_matrix(&jacobian, "jacobian")?;
let optimality_inf = validate_value((jacobian.transpose() * &residual).amax(), "optimality")?;
Ok(LeastSquaresReport {
x,
residual,
cost: cost_value,
jacobian,
optimality_inf,
iterations,
status,
})
}
fn validate_value(value: f64, field: &'static str) -> Result<f64, SolveError> {
crate::validate::finite(value, field).map_err(map_field_error)
}
fn validate_options(opts: &SolveOptions) -> Result<(), SolveError> {
crate::validate::positive_step(opts.gtol, "gtol").map_err(map_field_error)?;
crate::validate::positive_step(opts.ftol, "ftol").map_err(map_field_error)?;
crate::validate::positive_step(opts.xtol, "xtol").map_err(map_field_error)?;
if opts.max_nfev == 0 {
return Err(invalid_input("max_nfev", "not positive"));
}
Ok(())
}
fn validate_nonempty_vector(vector: &DVector<f64>, field: &'static str) -> Result<(), SolveError> {
if vector.is_empty() {
Err(invalid_input(field, "empty"))
} else {
Ok(())
}
}
fn validate_vector(vector: &DVector<f64>, field: &'static str) -> Result<(), SolveError> {
crate::validate::finite_slice(vector.as_slice(), field).map_err(map_field_error)
}
fn validate_matrix(matrix: &DMatrix<f64>, field: &'static str) -> Result<(), SolveError> {
crate::validate::finite_slice(matrix.as_slice(), field).map_err(map_field_error)
}
fn map_field_error(error: crate::validate::FieldError) -> SolveError {
invalid_input(error.field(), error.reason())
}
fn invalid_input(field: &'static str, reason: &'static str) -> SolveError {
SolveError::InvalidInput { field, reason }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fd_rel_step_is_sqrt_eps() {
assert_eq!(FD_REL_STEP_2POINT, (2.0_f64.powi(-52)).sqrt());
assert_eq!(FD_REL_STEP_2POINT, 2.0_f64.powi(-26));
}
#[test]
fn fd_step_sign_convention() {
let x0 = DVector::from_vec(vec![5.0, -2.0, 0.0]);
let steps = fd_steps(&x0, FD_REL_STEP_2POINT).unwrap();
assert_eq!(steps[0].sign_x0, 1.0);
assert_eq!(steps[1].sign_x0, -1.0);
assert_eq!(steps[2].sign_x0, 1.0); }
#[test]
fn fd_steps_rejects_zero_relative_step() {
let x0 = DVector::from_vec(vec![1.0]);
assert_invalid_field(fd_steps(&x0, 0.0).unwrap_err(), "rel_step");
}
#[test]
fn fd_steps_rejects_nonfinite_parameters() {
let x0 = DVector::from_vec(vec![1.0, f64::NAN]);
assert_invalid_field(fd_steps(&x0, FD_REL_STEP_2POINT).unwrap_err(), "parameters");
}
#[test]
fn jacobian_rejects_residual_length_mismatch() {
let x0 = DVector::from_vec(vec![1.0, 2.0]);
let f0 = DVector::from_vec(vec![1.0, 2.0]);
let residual = |_: &DVector<f64>| DVector::from_vec(vec![1.0]);
assert_invalid_field(jacobian_2point(residual, &x0, &f0).unwrap_err(), "residual");
}
#[test]
fn cost_rejects_nonfinite_residual() {
assert_invalid_field(
cost(&DVector::from_vec(vec![1.0, f64::INFINITY])).unwrap_err(),
"residual",
);
}
#[test]
fn exp_fit_converges() {
let t = vec![0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0];
let y = vec![
3.0123, 2.2083, 1.6889, 1.3713, 1.0903, 0.9302, 0.8104, 0.6303,
];
let tt = t.clone();
let yy = y.clone();
let residual = move |p: &DVector<f64>| {
let (a, b, c) = (p[0], p[1], p[2]);
DVector::from_iterator(
tt.len(),
tt.iter()
.zip(&yy)
.map(|(&tk, &yk)| a * (b * tk).exp() + c - yk),
)
};
let problem = LeastSquaresProblem::new(residual, DVector::from_vec(vec![5.0, -2.0, 2.0]));
let report = solve_trf(&problem, &SolveOptions::default()).unwrap();
assert!(report.cost < 1.0, "cost did not reduce: {}", report.cost);
}
#[test]
fn solve_trf_rejects_nonfinite_initial_residual() {
fn residual(_: &DVector<f64>) -> DVector<f64> {
DVector::from_element(1, f64::NAN)
}
let problem = LeastSquaresProblem::new(residual, DVector::from_element(1, 0.0));
assert_invalid_field(
solve_trf(&problem, &SolveOptions::default()).unwrap_err(),
"residual",
);
}
#[test]
fn solve_trf_rejects_nonfinite_initial_cost() {
fn residual(_: &DVector<f64>) -> DVector<f64> {
DVector::from_element(1, f64::MAX)
}
let problem = LeastSquaresProblem::new(residual, DVector::from_element(1, 0.0));
assert_invalid_field(
solve_trf(&problem, &SolveOptions::default()).unwrap_err(),
"cost",
);
}
#[test]
fn solve_trf_rejects_nonfinite_trial_residual_instead_of_converging() {
use std::cell::Cell;
let calls = Cell::new(0usize);
let residual = move |p: &DVector<f64>| {
let call = calls.get();
calls.set(call + 1);
if call >= 2 {
DVector::from_element(1, f64::NAN)
} else {
DVector::from_element(1, p[0])
}
};
let problem = LeastSquaresProblem::new(residual, DVector::from_element(1, 1.0));
assert_invalid_field(
solve_trf(&problem, &SolveOptions::default()).unwrap_err(),
"residual",
);
}
#[test]
fn solve_trf_rejects_invalid_options() {
fn residual(p: &DVector<f64>) -> DVector<f64> {
DVector::from_element(1, p[0])
}
let problem = LeastSquaresProblem::new(residual, DVector::from_element(1, 1.0));
let opts = SolveOptions {
gtol: f64::NAN,
..SolveOptions::default()
};
assert_invalid_field(solve_trf(&problem, &opts).unwrap_err(), "gtol");
let opts = SolveOptions {
max_nfev: 0,
..SolveOptions::default()
};
assert_invalid_field(solve_trf(&problem, &opts).unwrap_err(), "max_nfev");
}
#[test]
fn solve_trf_rejects_weight_residual_dimension_mismatch() {
fn residual(_: &DVector<f64>) -> DVector<f64> {
DVector::from_vec(vec![1.0, 2.0])
}
let problem = LeastSquaresProblem::with_weights(
residual,
DVector::from_element(1, 0.0),
DVector::from_vec(vec![1.0]),
);
assert_invalid_field(
solve_trf(&problem, &SolveOptions::default()).unwrap_err(),
"weights",
);
}
fn assert_invalid_field(error: SolveError, expected: &'static str) {
match error {
SolveError::InvalidInput { field, .. } => assert_eq!(field, expected),
other => panic!("expected invalid input for {expected}, got {other:?}"),
}
}
fn exp_fit_problem() -> LeastSquaresProblem<impl Fn(&DVector<f64>) -> DVector<f64>> {
let t = [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0];
let y = [
3.0123, 2.2083, 1.6889, 1.3713, 1.0903, 0.9302, 0.8104, 0.6303,
];
let residual = move |p: &DVector<f64>| {
let (a, b, c) = (p[0], p[1], p[2]);
DVector::from_iterator(
t.len(),
t.iter()
.zip(&y)
.map(|(&tk, &yk)| a * (b * tk).exp() + c - yk),
)
};
LeastSquaresProblem::new(residual, DVector::from_vec(vec![5.0, -2.0, 2.0]))
}
#[test]
fn owned_trf_converges_to_frozen_bits() {
let problem = exp_fit_problem();
let report = solve_trf_with(
&problem,
&SolveOptions::default(),
TrustRegionSolve::OwnedGaussianFirstTie,
)
.unwrap();
assert!(
report.cost < 1.0,
"owned cost did not reduce: {}",
report.cost
);
assert_eq!(report.x[0].to_bits(), 0x4003c3674cdfadef);
assert_eq!(report.x[1].to_bits(), 0xbfe799e0d1929220);
assert_eq!(report.x[2].to_bits(), 0x3fe0d5c96d9d3b35);
let again = solve_trf_with(
&problem,
&SolveOptions::default(),
TrustRegionSolve::OwnedGaussianFirstTie,
)
.unwrap();
for i in 0..3 {
assert_eq!(report.x[i].to_bits(), again.x[i].to_bits());
}
}
fn covariance_fixture_jacobian() -> DMatrix<f64> {
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])
}
#[test]
fn hessian_trace_matches_numpy() {
let trace = hessian_trace(&covariance_fixture_jacobian());
assert!((trace - 35.0).abs() < 1e-12, "trace {trace}");
}
#[test]
fn normal_covariance_matches_numpy_pcov() {
let inv = normal_covariance(&covariance_fixture_jacobian(), 1.0).unwrap();
let expected = [[0.6000000000000001, -0.2], [-0.2, 0.1]];
for i in 0..2 {
for j in 0..2 {
assert!(
(inv[(i, j)] - expected[i][j]).abs() < 1e-12,
"inv[{i}][{j}] = {}",
inv[(i, j)]
);
}
}
let s_sq = 0.085 / 3.0;
let cov = normal_covariance(&covariance_fixture_jacobian(), s_sq).unwrap();
let expected_cov = [
[0.017000000000000005, -0.005666666666666667],
[-0.005666666666666667, 0.0028333333333333335],
];
for i in 0..2 {
for j in 0..2 {
assert!(
(cov[(i, j)] - expected_cov[i][j]).abs() < 1e-12,
"cov[{i}][{j}] = {}",
cov[(i, j)]
);
}
}
}
#[test]
fn normal_covariance_rejects_underdetermined_and_negative_scale() {
let wide = DMatrix::from_row_slice(2, 3, &[1.0, 0.0, 1.0, 0.0, 1.0, 1.0]);
assert!(matches!(
normal_covariance(&wide, 1.0),
Err(SolveError::InvalidInput {
field: "jacobian",
..
})
));
assert!(matches!(
normal_covariance(&covariance_fixture_jacobian(), -1.0),
Err(SolveError::InvalidInput {
field: "variance_scale",
..
})
));
}
#[test]
fn normal_covariance_matches_closed_form_inverse_for_collinear_jacobian() {
let eps = 1e-2;
let col1: Vec<f64> = (0..5).map(|k| 1.0 + (k as f64) * eps).collect();
let mut data = Vec::with_capacity(10);
for &c1 in &col1 {
data.push(1.0);
data.push(c1);
}
let jac = DMatrix::from_row_slice(5, 2, &data);
let scale = 2.5;
let cov = normal_covariance(&jac, scale).unwrap();
let s00 = 5.0_f64;
let s01: f64 = col1.iter().sum();
let s11: f64 = col1.iter().map(|c| c * c).sum();
let det = s00 * s11 - s01 * s01;
let inv = [[s11 / det, -s01 / det], [-s01 / det, s00 / det]];
for i in 0..2 {
for j in 0..2 {
let expected = inv[i][j] * scale;
let tol = 1e-9 * expected.abs().max(1.0);
assert!(
(cov[(i, j)] - expected).abs() < tol,
"cov[{i}][{j}] = {} (expected {expected})",
cov[(i, j)]
);
}
}
assert!((cov[(0, 1)] - cov[(1, 0)]).abs() <= 1e-12 * cov[(0, 0)].abs().max(1.0));
}
#[test]
fn covariance_from_report_rejects_jacobian_dimension_mismatch() {
let jac = covariance_fixture_jacobian(); let mismatched_rows = LeastSquaresReport {
x: DVector::from_vec(vec![0.0, 0.0]),
residual: DVector::from_vec(vec![0.1, -0.2, 0.15, 0.05]), cost: 0.1,
jacobian: jac.clone(),
optimality_inf: 0.0,
iterations: 0,
status: Status::GradientTolerance,
};
assert_invalid_field(
covariance_from_report(&mismatched_rows).unwrap_err(),
"jacobian",
);
let mismatched_cols = LeastSquaresReport {
x: DVector::from_vec(vec![0.0, 0.0, 0.0]), residual: DVector::from_vec(vec![0.1, -0.2, 0.15, 0.05, -0.1]),
cost: 0.1,
jacobian: jac,
optimality_inf: 0.0,
iterations: 0,
status: Status::GradientTolerance,
};
assert_invalid_field(
covariance_from_report(&mismatched_cols).unwrap_err(),
"jacobian",
);
}
#[test]
fn covariance_from_jacobian_matches_report_path_bit_for_bit() {
let jac = covariance_fixture_jacobian(); let residual = DVector::from_vec(vec![0.1, -0.2, 0.15, 0.05, -0.1]);
let cost = 0.5 * residual.dot(&residual);
let report = LeastSquaresReport {
x: DVector::from_vec(vec![0.0, 0.0]),
cost,
residual,
jacobian: jac.clone(),
optimality_inf: 0.0,
iterations: 0,
status: Status::GradientTolerance,
};
let from_jac = covariance_from_jacobian(&jac, cost).unwrap();
let from_report = covariance_from_report(&report).unwrap();
let m = jac.nrows();
let n = jac.ncols();
let explicit = normal_covariance(&jac, 2.0 * cost / ((m - n) as f64)).unwrap();
assert_eq!(from_jac.shape(), from_report.shape());
for (a, (b, c)) in from_jac.iter().zip(from_report.iter().zip(explicit.iter())) {
assert_eq!(a.to_bits(), b.to_bits());
assert_eq!(a.to_bits(), c.to_bits());
}
}
#[test]
fn covariance_from_jacobian_rejects_insufficient_dof() {
let square = DMatrix::from_row_slice(2, 2, &[1.0, 0.0, 1.0, 1.0]);
assert_invalid_field(
covariance_from_jacobian(&square, 0.1).unwrap_err(),
"degrees_of_freedom",
);
let wide = DMatrix::from_row_slice(2, 3, &[1.0, 0.0, 1.0, 0.0, 1.0, 1.0]);
assert_invalid_field(
covariance_from_jacobian(&wide, 0.1).unwrap_err(),
"degrees_of_freedom",
);
}
#[test]
fn covariance_from_report_uses_reduced_chi_square() {
let jac = covariance_fixture_jacobian();
let residual = DVector::from_vec(vec![0.1, -0.2, 0.15, 0.05, -0.1]);
let report = LeastSquaresReport {
x: DVector::from_vec(vec![0.0, 0.0]),
cost: 0.5 * residual.dot(&residual),
residual,
jacobian: jac,
optimality_inf: 0.0,
iterations: 0,
status: Status::GradientTolerance,
};
let cov = covariance_from_report(&report).unwrap();
let expected_cov = [
[0.017000000000000005, -0.005666666666666667],
[-0.005666666666666667, 0.0028333333333333335],
];
for i in 0..2 {
for j in 0..2 {
assert!(
(cov[(i, j)] - expected_cov[i][j]).abs() < 1e-12,
"cov[{i}][{j}] = {}",
cov[(i, j)]
);
}
}
}
}