Skip to main content

apex_solver/linalg/
covariance.rs

1//! Parameter covariance estimation.
2//!
3//! Covariance is a property of the **problem at a point**, not a byproduct of the
4//! solver's last linear system. This module re-linearizes the problem at a given
5//! set of variable values and inverts a clean Gauss-Newton Hessian:
6//!
7//! ```text
8//! H = JᵀJ
9//! Σ = H⁻¹
10//! ```
11//!
12//! Crucially, neither Levenberg-Marquardt damping `λ` nor Jacobi column scaling
13//! `D` appears anywhere in that computation. Both are internal devices that make
14//! the *step* well-behaved; neither carries information about the estimate's
15//! uncertainty, and a user flipping [`with_jacobi_scaling`] or `damping` must see
16//! identical covariances.
17//!
18//! [`with_jacobi_scaling`]: crate::optimizer::levenberg_marquardt::LevenbergMarquardtConfig::with_jacobi_scaling
19//!
20//! # Robust loss functions
21//!
22//! When a residual block carries a loss function, `J` here is the Triggs-corrected
23//! Jacobian produced during assembly, so `H = J̃ᵀJ̃` is the Gauss-Newton
24//! approximation of the Hessian of the **robust** objective. This matches Ceres
25//! Solver, and gives the Laplace approximation of the posterior under the robust
26//! cost. It is deliberately *not* the Huber/White sandwich estimator `A⁻¹BA⁻¹`
27//! used for robust standard errors in the statistics literature.
28//!
29//! # Units
30//!
31//! `H⁻¹` is a covariance only if the residuals are already whitened by their
32//! measurement information matrix (the usual convention in SLAM and bundle
33//! adjustment). For unweighted least squares, set
34//! [`CovarianceOptions::apply_variance_scaling`] to estimate the noise scale from
35//! the fit residuals.
36//!
37//! # Example
38//!
39//! ```no_run
40//! use apex_solver::linalg::covariance::{Covariance, CovarianceOptions};
41//! # use apex_solver::core::problem::Problem;
42//! # fn example(problem: &Problem, result_parameters: &slotmap::SlotMap<apex_solver::core::VarKey, Box<dyn apex_solver::core::variable::ManifoldVariable>>) -> Result<(), Box<dyn std::error::Error>> {
43//! let covariance = Covariance::compute(
44//!     CovarianceOptions::default(),
45//!     problem,
46//!     result_parameters,
47//! )?;
48//! # Ok(())
49//! # }
50//! ```
51
52use faer::{
53    Mat, MatRef, Side,
54    linalg::solvers::Solve,
55    sparse::linalg::solvers::{Llt, SymbolicLlt},
56};
57use slotmap::{SecondaryMap, SlotMap};
58use thiserror::Error;
59use tracing::warn;
60
61use crate::core::{VarKey, problem::Problem, variable::ManifoldVariable};
62use crate::linearizer::LinearizerError;
63
64// ============================================================================
65// Options
66// ============================================================================
67
68/// How to invert the Gauss-Newton Hessian.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
70pub enum CovarianceAlgorithm {
71    /// Sparse Cholesky factorization. Fast and memory-efficient, but requires
72    /// `H` to be positive definite — i.e. the problem must be fully constrained.
73    ///
74    /// A rank-deficient `H` (unfixed gauge freedom, or a variable no residual
75    /// observes) produces [`CovarianceError::RankDeficient`].
76    #[default]
77    SparseCholesky,
78
79    /// Dense SVD, yielding the Moore-Penrose pseudo-inverse.
80    ///
81    /// Handles rank-deficient `H` — the pseudo-inverse reports zero variance
82    /// along the null space, which is the correct answer for an intentionally
83    /// gauge-free problem. Costs `O(n³)` time and `O(n²)` memory, so this is only
84    /// viable for small problems.
85    DenseSvd,
86}
87
88/// Configuration for [`Covariance::compute`].
89///
90/// All fields are plain values, so this is `Copy` and passed by value.
91#[derive(Debug, Clone, Copy)]
92pub struct CovarianceOptions {
93    /// Factorization strategy. See [`CovarianceAlgorithm`].
94    pub algorithm: CovarianceAlgorithm,
95
96    /// Multiply the result by the estimated noise variance
97    /// `σ̂² = 2·cost / (m − n)`, where `m` is the total residual dimension and
98    /// `n` the total tangent-space dimension.
99    ///
100    /// Leave this `false` (the default) when residuals are already whitened by
101    /// their measurement information matrix — then `H⁻¹` is directly the
102    /// covariance. Set it `true` for unweighted least squares, where the noise
103    /// scale has to be estimated from the fit.
104    pub apply_variance_scaling: bool,
105
106    /// Singular values below `σ_max · min_reciprocal_condition_number` are treated
107    /// as zero by [`CovarianceAlgorithm::DenseSvd`]. Ignored by `SparseCholesky`.
108    pub min_reciprocal_condition_number: f64,
109}
110
111impl Default for CovarianceOptions {
112    fn default() -> Self {
113        Self {
114            algorithm: CovarianceAlgorithm::default(),
115            apply_variance_scaling: false,
116            min_reciprocal_condition_number: 1e-14,
117        }
118    }
119}
120
121impl CovarianceOptions {
122    /// Create options with default settings.
123    pub fn new() -> Self {
124        Self::default()
125    }
126
127    /// Select the factorization strategy.
128    pub fn with_algorithm(mut self, algorithm: CovarianceAlgorithm) -> Self {
129        self.algorithm = algorithm;
130        self
131    }
132
133    /// Enable or disable estimating the noise scale from the fit residuals.
134    pub fn with_variance_scaling(mut self, apply_variance_scaling: bool) -> Self {
135        self.apply_variance_scaling = apply_variance_scaling;
136        self
137    }
138
139    /// Set the singular-value cutoff used by [`CovarianceAlgorithm::DenseSvd`].
140    pub fn with_min_reciprocal_condition_number(mut self, value: f64) -> Self {
141        self.min_reciprocal_condition_number = value;
142        self
143    }
144}
145
146// ============================================================================
147// Errors
148// ============================================================================
149
150/// Why covariance estimation failed.
151///
152/// Covariance never fails silently: a caller that asked for it either gets a
153/// matrix or a reason.
154#[derive(Debug, Error)]
155pub enum CovarianceError {
156    /// `H = JᵀJ` is singular or not positive definite.
157    #[error(
158        "Hessian is singular or not positive definite ({context}). This usually means \
159         unconstrained gauge freedom (e.g. a pose graph with no prior anchoring the first \
160         pose) or a variable that no residual observes. Add a PriorFactor to fix the gauge, \
161         or use CovarianceAlgorithm::DenseSvd to obtain the pseudo-inverse."
162    )]
163    RankDeficient {
164        /// What the chosen algorithm was able to determine about the deficiency.
165        context: String,
166    },
167
168    /// The problem could not be linearized at the given variables.
169    #[error("Failed to linearize the problem for covariance estimation: {0}")]
170    Linearization(#[from] LinearizerError),
171
172    /// Covariance is undefined without variables or residuals.
173    #[error("Covariance requires at least one variable and one residual block")]
174    EmptyProblem,
175
176    /// A matrix operation during Hessian assembly failed.
177    #[error("Failed to assemble the Hessian for covariance estimation: {0}")]
178    Assembly(String),
179
180    /// The dense SVD itself failed to converge.
181    #[error("SVD failed during covariance estimation: {0}")]
182    SvdFailed(String),
183}
184
185/// Result type for covariance estimation.
186pub type CovarianceResult<T> = Result<T, CovarianceError>;
187
188// ============================================================================
189// Covariance
190// ============================================================================
191
192/// Parameter covariance for a problem evaluated at a set of variable values.
193///
194/// Blocks are in **tangent space**: a variable with `dof() == d` has a `d × d`
195/// covariance block, regardless of its ambient parameterization. For an `SE3`
196/// pose this is `6 × 6`, even though the variable stores 7 numbers. Fixed
197/// tangent coordinates are excluded from `H` before inversion (see
198/// `codex/issues/ISSUE-0003-fixed-dofs-solved-as-free.md`) and appear as
199/// exact `0.0` rows/columns in [`block`](Covariance::block)/
200/// [`block_pair`](Covariance::block_pair) — a fixed coordinate is held at a
201/// known constant, so it has zero variance and zero covariance with
202/// everything else by construction, not by approximation.
203#[derive(Debug, Clone)]
204pub struct Covariance {
205    /// `H⁻¹` restricted to free columns only — `total_free_dof × total_free_dof`.
206    matrix: Mat<f64>,
207    /// Free-column offset per variable, in the same reduced space as `matrix`.
208    index_map: SecondaryMap<VarKey, usize>,
209    /// Full (un-reduced) tangent dimension per variable — the shape callers see.
210    full_dofs: SecondaryMap<VarKey, usize>,
211    /// Fixed local tangent indices per variable, used to place exact zeros.
212    fixed_indices: SecondaryMap<VarKey, std::collections::HashSet<usize>>,
213}
214
215/// Map a local tangent index to its offset in free-column space, or `None`
216/// if fixed — same rule as `ManifoldVariable::local_free_offset`, inlined
217/// here since `Covariance` stores plain `HashSet`s rather than variable
218/// trait objects.
219fn local_free_offset(fixed: &std::collections::HashSet<usize>, local: usize) -> Option<usize> {
220    if fixed.is_empty() {
221        return Some(local);
222    }
223    if fixed.contains(&local) {
224        return None;
225    }
226    Some(local - fixed.iter().filter(|&&i| i < local).count())
227}
228
229impl Covariance {
230    /// Compute the covariance by re-linearizing `problem` at `variables`.
231    ///
232    /// `variables` is normally `SolverResult::parameters`, i.e. the solution the
233    /// optimizer returned.
234    pub fn compute(
235        options: CovarianceOptions,
236        problem: &Problem,
237        variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
238    ) -> CovarianceResult<Self> {
239        if variables.is_empty() || problem.num_residual_blocks() == 0 {
240            return Err(CovarianceError::EmptyProblem);
241        }
242
243        // Free-column space: fixed tangent coordinates own no column here, so
244        // `H` below never contains them and this is a true conditional
245        // covariance, not a marginal that over-states free-parameter
246        // uncertainty (see `block`/`block_pair`, which reinsert them as
247        // exact zeros for the caller).
248        let (index_map, _sorted_vars, total_dof) =
249            crate::optimizer::build_variable_index_map(variables);
250
251        // Re-linearize at the given point. Note this deliberately ignores any
252        // `use_jacobi_scaling` setting: the covariance must not depend on the
253        // preconditioning the optimizer happened to use.
254        let symbolic = crate::linearizer::cpu::sparse::build_symbolic_structure(
255            problem, variables, &index_map, total_dof,
256        )?;
257        let mut workspace = crate::linearizer::AssemblyWorkspace::build(problem);
258        let (residuals, jacobian) = crate::linearizer::cpu::sparse::assemble_sparse(
259            problem,
260            variables,
261            &index_map,
262            &symbolic,
263            &mut workspace,
264        )?;
265
266        // H = JᵀJ via parallel faer kernels with cached symbolic structure.
267        // No damping term, no column scaling.
268        let hessian = {
269            let mut ne_cache = crate::linalg::sparse::normal_eq::LazyNormalEquations::default();
270            ne_cache
271                .compute(&residuals, &jacobian)
272                .map_err(|e| CovarianceError::Assembly(format!("failed to form H = JᵀJ: {e}")))?
273                .hessian
274        };
275
276        let mut matrix = match options.algorithm {
277            CovarianceAlgorithm::SparseCholesky => invert_via_cholesky(&hessian, total_dof)?,
278            CovarianceAlgorithm::DenseSvd => {
279                invert_via_svd(&hessian, total_dof, options.min_reciprocal_condition_number)?
280            }
281        };
282
283        if options.apply_variance_scaling
284            && let Some(sigma_squared) = estimate_variance(problem, &residuals, total_dof)
285        {
286            matrix *= faer::Scale(sigma_squared);
287        }
288
289        let full_dofs = variables.iter().map(|(k, v)| (k, v.dof())).collect();
290        let fixed_indices = variables
291            .iter()
292            .map(|(k, v)| (k, v.get_fixed_indices().clone()))
293            .collect();
294
295        Ok(Self {
296            matrix,
297            index_map,
298            full_dofs,
299            fixed_indices,
300        })
301    }
302
303    /// Marginal covariance of one variable: a `dof × dof` block in tangent
304    /// space, with exact `0.0` rows/columns at any fixed local index.
305    pub fn block(&self, key: VarKey) -> Option<Mat<f64>> {
306        self.block_pair(key, key)
307    }
308
309    /// Cross-covariance between two variables: a `dof_a × dof_b` block, with
310    /// exact `0.0` rows/columns at any fixed local index of either variable.
311    pub fn block_pair(&self, a: VarKey, b: VarKey) -> Option<Mat<f64>> {
312        let (&free_row0, &free_col0) = (self.index_map.get(a)?, self.index_map.get(b)?);
313        let (full_a, full_b) = (*self.full_dofs.get(a)?, *self.full_dofs.get(b)?);
314        let (fixed_a, fixed_b) = (self.fixed_indices.get(a)?, self.fixed_indices.get(b)?);
315
316        Some(Mat::from_fn(full_a, full_b, |local_a, local_b| {
317            let (Some(free_a), Some(free_b)) = (
318                local_free_offset(fixed_a, local_a),
319                local_free_offset(fixed_b, local_b),
320            ) else {
321                return 0.0;
322            };
323            self.matrix[(free_row0 + free_a, free_col0 + free_b)]
324        }))
325    }
326
327    /// Every variable's marginal block, keyed by variable.
328    pub fn per_variable(&self) -> SecondaryMap<VarKey, Mat<f64>> {
329        self.index_map
330            .keys()
331            .filter_map(|key| Some((key, self.block(key)?)))
332            .collect()
333    }
334
335    /// The full reduced covariance matrix, `total_free_dof × total_free_dof`
336    /// — free columns only, in the same space as [`index_map`](Self::index_map).
337    /// Use [`block`](Self::block)/[`block_pair`](Self::block_pair) for the
338    /// per-variable view with fixed coordinates reinserted as exact zeros.
339    pub fn full(&self) -> MatRef<'_, f64> {
340        self.matrix.as_ref()
341    }
342
343    /// Free-column offset of each variable within [`full`](Self::full).
344    pub fn index_map(&self) -> &SecondaryMap<VarKey, usize> {
345        &self.index_map
346    }
347}
348
349// ============================================================================
350// Inversion strategies
351// ============================================================================
352
353/// Invert `H` via a fresh sparse Cholesky factorization.
354///
355/// The factorization is built here rather than reused from the optimizer, which
356/// is the entire point: the optimizer's cached factorizer is of `H + λI`.
357fn invert_via_cholesky(
358    hessian: &faer::sparse::SparseColMat<usize, f64>,
359    n: usize,
360) -> CovarianceResult<Mat<f64>> {
361    let symbolic = SymbolicLlt::try_new(hessian.symbolic(), Side::Lower).map_err(|e| {
362        CovarianceError::RankDeficient {
363            context: format!("symbolic Cholesky analysis failed: {e:?}"),
364        }
365    })?;
366    let llt = Llt::try_new_with_symbolic(symbolic, hessian.as_ref(), Side::Lower).map_err(|e| {
367        CovarianceError::RankDeficient {
368            context: format!("numeric Cholesky factorization failed: {e:?}"),
369        }
370    })?;
371
372    Ok(llt.solve(Mat::<f64>::identity(n, n)))
373}
374
375/// Invert `H` via a dense SVD, yielding the Moore-Penrose pseudo-inverse.
376///
377/// Singular values below `σ_max · rcond` are treated as exactly zero, so the
378/// result reports zero variance along the null space of `H` — the correct answer
379/// for a problem with intentional gauge freedom.
380fn invert_via_svd(
381    hessian: &faer::sparse::SparseColMat<usize, f64>,
382    n: usize,
383    rcond: f64,
384) -> CovarianceResult<Mat<f64>> {
385    let dense = Mat::from_fn(n, n, |i, j| hessian.get(i, j).copied().unwrap_or(0.0));
386
387    let svd = dense
388        .as_ref()
389        .svd()
390        .map_err(|e| CovarianceError::SvdFailed(format!("{e:?}")))?;
391
392    let singular = svd.S();
393    let sigma_max = (0..n).map(|i| singular[i]).fold(0.0_f64, f64::max);
394    if sigma_max <= 0.0 {
395        return Err(CovarianceError::RankDeficient {
396            context: "Hessian is entirely zero (rank 0)".to_string(),
397        });
398    }
399
400    let threshold = sigma_max * rcond;
401    let inverse_singular: Vec<f64> = (0..n)
402        .map(|i| {
403            let s = singular[i];
404            if s > threshold { 1.0 / s } else { 0.0 }
405        })
406        .collect();
407    let rank = inverse_singular.iter().filter(|&&x| x != 0.0).count();
408
409    if rank < n {
410        warn!(
411            "Hessian is rank deficient: numeric rank {rank} of {n}. Reporting the \
412             Moore-Penrose pseudo-inverse, which assigns zero variance along the {} \
413             null-space direction(s). This usually indicates unconstrained gauge freedom.",
414            n - rank
415        );
416    }
417
418    // pinv(H) = V · diag(1/σ, thresholded) · Uᵀ
419    let (u, v) = (svd.U(), svd.V());
420    let mut scaled_v = Mat::<f64>::zeros(n, n);
421    for j in 0..n {
422        for i in 0..n {
423            scaled_v[(i, j)] = v[(i, j)] * inverse_singular[j];
424        }
425    }
426
427    Ok(scaled_v * u.transpose())
428}
429
430/// Estimate the noise variance `σ̂² = 2·cost / (m − n)` from the fit residuals.
431///
432/// Returns `None` (with a warning) when there are no residual degrees of freedom
433/// left to estimate a scale from.
434fn estimate_variance(problem: &Problem, residuals: &Mat<f64>, n: usize) -> Option<f64> {
435    let m = problem.total_residual_dimension();
436    if m <= n {
437        warn!(
438            "Cannot estimate noise variance: {m} residuals for {n} parameters leaves no \
439             residual degrees of freedom. Returning the unscaled H^-1."
440        );
441        return None;
442    }
443    let cost = 0.5 * residuals.squared_norm_l2();
444    Some(2.0 * cost / (m - n) as f64)
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use crate::ManifoldType;
451    use crate::factors::Factor;
452    use crate::linalg::JacobianMode;
453    use faer::prelude::ReborrowMut;
454    use nalgebra::dvector;
455
456    type TestResult = Result<(), Box<dyn std::error::Error>>;
457
458    /// `r = a·x − b`, so `J = [a]` and `H = a²` for a single scalar variable.
459    struct ScaledFactor {
460        a: f64,
461        b: f64,
462    }
463
464    impl Factor for ScaledFactor {
465        fn linearize(
466            &self,
467            params: &[&[f64]],
468            residual: &mut [f64],
469            jacobian: Option<faer::mat::MatMut<'_, f64>>,
470        ) {
471            residual[0] = self.a * params[0][0] - self.b;
472            if let Some(mut jac) = jacobian {
473                *jac.rb_mut().get_mut(0, 0) = self.a;
474            }
475        }
476        fn residual_dim(&self) -> usize {
477            1
478        }
479        fn jacobian_shape(&self) -> (usize, usize) {
480            (1, 1)
481        }
482    }
483
484    /// `r = x1 − x2`, an SE2-free relative constraint on two scalars.
485    /// Two of these leave the pair's common offset unobserved — a 1-D gauge freedom.
486    struct DifferenceFactor;
487
488    impl Factor for DifferenceFactor {
489        fn linearize(
490            &self,
491            params: &[&[f64]],
492            residual: &mut [f64],
493            jacobian: Option<faer::mat::MatMut<'_, f64>>,
494        ) {
495            residual[0] = params[0][0] - params[1][0];
496            if let Some(mut jac) = jacobian {
497                *jac.rb_mut().get_mut(0, 0) = 1.0;
498                *jac.rb_mut().get_mut(0, 1) = -1.0;
499            }
500        }
501        fn residual_dim(&self) -> usize {
502            1
503        }
504        fn jacobian_shape(&self) -> (usize, usize) {
505            (1, 2)
506        }
507    }
508
509    /// Two independent scalars observed with different precisions:
510    /// `H = diag(4, 100)`, so `Σ = diag(0.25, 0.01)`.
511    fn two_variable_problem() -> (Problem, VarKey, VarKey) {
512        let mut problem = Problem::new(JacobianMode::Sparse);
513        let x = problem.add_variable(ManifoldType::RN, dvector![0.0]);
514        let y = problem.add_variable(ManifoldType::RN, dvector![0.0]);
515        problem.add_residual_block(&[x], Box::new(ScaledFactor { a: 2.0, b: 1.0 }), None);
516        problem.add_residual_block(&[y], Box::new(ScaledFactor { a: 10.0, b: 1.0 }), None);
517        (problem, x, y)
518    }
519
520    #[test]
521    fn computes_analytic_inverse_hessian() -> TestResult {
522        let (problem, x, y) = two_variable_problem();
523        let cov = Covariance::compute(
524            CovarianceOptions::default(),
525            &problem,
526            &problem.variables.clone(),
527        )?;
528
529        let sigma_x = cov.block(x).ok_or("missing block for x")?;
530        let sigma_y = cov.block(y).ok_or("missing block for y")?;
531
532        assert!(
533            (sigma_x[(0, 0)] - 0.25).abs() < 1e-12,
534            "expected 1/a² = 0.25, got {}",
535            sigma_x[(0, 0)]
536        );
537        assert!(
538            (sigma_y[(0, 0)] - 0.01).abs() < 1e-12,
539            "expected 1/a² = 0.01, got {}",
540            sigma_y[(0, 0)]
541        );
542        Ok(())
543    }
544
545    #[test]
546    fn independent_variables_have_zero_cross_covariance() -> TestResult {
547        let (problem, x, y) = two_variable_problem();
548        let cov = Covariance::compute(
549            CovarianceOptions::default(),
550            &problem,
551            &problem.variables.clone(),
552        )?;
553
554        let cross = cov.block_pair(x, y).ok_or("missing cross block")?;
555        assert!(cross[(0, 0)].abs() < 1e-12, "got {}", cross[(0, 0)]);
556        Ok(())
557    }
558
559    #[test]
560    fn per_variable_matches_individual_blocks() -> TestResult {
561        let (problem, x, y) = two_variable_problem();
562        let cov = Covariance::compute(
563            CovarianceOptions::default(),
564            &problem,
565            &problem.variables.clone(),
566        )?;
567
568        let per_var = cov.per_variable();
569        assert_eq!(per_var.len(), 2);
570        for key in [x, y] {
571            let expected = cov.block(key).ok_or("missing block")?;
572            let actual = per_var.get(key).ok_or("missing per-variable entry")?;
573            assert!((actual[(0, 0)] - expected[(0, 0)]).abs() < 1e-15);
574        }
575        Ok(())
576    }
577
578    /// A gauge-free problem must fail loudly under Cholesky rather than silently
579    /// returning whatever damping happened to make invertible. See issue #38.
580    #[test]
581    fn rank_deficient_problem_reports_error_under_cholesky() {
582        let mut problem = Problem::new(JacobianMode::Sparse);
583        let x = problem.add_variable(ManifoldType::RN, dvector![0.0]);
584        let y = problem.add_variable(ManifoldType::RN, dvector![0.0]);
585        problem.add_residual_block(&[x, y], Box::new(DifferenceFactor), None);
586
587        let variables = problem.variables.clone();
588        match Covariance::compute(CovarianceOptions::default(), &problem, &variables) {
589            Ok(_) => panic!("rank-deficient H must not silently succeed"),
590            Err(err) => {
591                assert!(
592                    matches!(err, CovarianceError::RankDeficient { .. }),
593                    "expected RankDeficient, got {err:?}"
594                );
595                let message = err.to_string();
596                assert!(
597                    message.contains("PriorFactor") && message.contains("DenseSvd"),
598                    "error must tell the user how to proceed, got: {message}"
599                );
600            }
601        }
602    }
603
604    /// The same gauge-free problem succeeds under `DenseSvd`, which reports the
605    /// pseudo-inverse: zero variance along the unobserved common-offset direction.
606    #[test]
607    fn rank_deficient_problem_succeeds_under_dense_svd() -> TestResult {
608        let mut problem = Problem::new(JacobianMode::Sparse);
609        let x = problem.add_variable(ManifoldType::RN, dvector![0.0]);
610        let y = problem.add_variable(ManifoldType::RN, dvector![0.0]);
611        problem.add_residual_block(&[x, y], Box::new(DifferenceFactor), None);
612
613        let variables = problem.variables.clone();
614        let cov = Covariance::compute(
615            CovarianceOptions::new().with_algorithm(CovarianceAlgorithm::DenseSvd),
616            &problem,
617            &variables,
618        )?;
619
620        // H = [[1, -1], [-1, 1]] has eigenvalues {2, 0}. Its pseudo-inverse is
621        // [[0.25, -0.25], [-0.25, 0.25]].
622        let full = cov.full();
623        for (i, j, expected) in [(0, 0, 0.25), (0, 1, -0.25), (1, 0, -0.25), (1, 1, 0.25)] {
624            assert!(
625                (full[(i, j)] - expected).abs() < 1e-10,
626                "pinv[{i},{j}] = {} expected {expected}",
627                full[(i, j)]
628            );
629        }
630
631        // The null-space direction (1, 1) must carry zero variance.
632        let ones = Mat::from_fn(2, 1, |_, _| 1.0);
633        let quadratic = (ones.transpose() * full * &ones)[(0, 0)];
634        assert!(quadratic.abs() < 1e-10, "got {quadratic}");
635        Ok(())
636    }
637
638    #[test]
639    fn empty_problem_is_rejected() {
640        let problem = Problem::new(JacobianMode::Sparse);
641        let variables = problem.variables.clone();
642        match Covariance::compute(CovarianceOptions::default(), &problem, &variables) {
643            Ok(_) => panic!("empty problem must be rejected"),
644            Err(err) => assert!(matches!(err, CovarianceError::EmptyProblem), "got {err:?}"),
645        }
646    }
647
648    /// With `m == n` there are no residual degrees of freedom, so variance
649    /// scaling is skipped rather than dividing by zero.
650    #[test]
651    fn variance_scaling_skipped_without_residual_dof() -> TestResult {
652        let (problem, x, _) = two_variable_problem();
653        let variables = problem.variables.clone();
654
655        let unscaled = Covariance::compute(CovarianceOptions::default(), &problem, &variables)?;
656        let scaled = Covariance::compute(
657            CovarianceOptions::new().with_variance_scaling(true),
658            &problem,
659            &variables,
660        )?;
661
662        let a = unscaled.block(x).ok_or("missing block")?[(0, 0)];
663        let b = scaled.block(x).ok_or("missing block")?[(0, 0)];
664        assert!((a - b).abs() < 1e-15, "{a} vs {b}");
665        Ok(())
666    }
667
668    /// With `m > n`, `Σ` is scaled by `σ̂² = 2·cost / (m − n)`.
669    #[test]
670    fn variance_scaling_applies_estimated_noise() -> TestResult {
671        let mut problem = Problem::new(JacobianMode::Sparse);
672        let x = problem.add_variable(ManifoldType::RN, dvector![0.0]);
673        // Two inconsistent observations of the same scalar: H = 2, and at x = 0
674        // the residuals are (-1, -3), so cost = 0.5·10 = 5 and σ̂² = 2·5/(2−1) = 10.
675        problem.add_residual_block(&[x], Box::new(ScaledFactor { a: 1.0, b: 1.0 }), None);
676        problem.add_residual_block(&[x], Box::new(ScaledFactor { a: 1.0, b: 3.0 }), None);
677
678        let variables = problem.variables.clone();
679        let cov = Covariance::compute(
680            CovarianceOptions::new().with_variance_scaling(true),
681            &problem,
682            &variables,
683        )?;
684
685        let value = cov.block(x).ok_or("missing block")?[(0, 0)];
686        let expected = 10.0 * 0.5; // σ̂² · H⁻¹
687        assert!(
688            (value - expected).abs() < 1e-12,
689            "expected {expected}, got {value}"
690        );
691        Ok(())
692    }
693}