Skip to main content

apex_solver/core/
noise.rs

1//! Measurement uncertainty for residual blocks.
2//!
3//! A [`NoiseModel`] carries the square-root information matrix `S` of an
4//! edge's information matrix `Ω` (`Ω = SᵀS`). Residuals and Jacobians are
5//! **whitened** (`r̃ = S·r`, `J̃ = S·J`) upstream of the robust-loss
6//! corrector, so the optimized objective becomes
7//!
8//! ```text
9//! F(x) = Σ ½·ρ(‖Sᵢ·rᵢ(x)‖²)
10//! ```
11//!
12//! — the Ω-weighted objective that g2o-style benchmarks report — while the
13//! Triggs correction, cost accounting and covariance semantics compose
14//! unchanged. [`NoiseModel::Null`] is the zero-cost identity default: with it,
15//! no allocation happens and no arithmetic is performed.
16
17use nalgebra::{DMatrix, DVector};
18
19use crate::core::CoreError;
20use crate::core::CoreResult;
21
22/// Square-root information model for one residual block.
23///
24/// `S` upper-factors `Ω = SᵀS`; whitening is `r̃ = S·r`, `J̃ = S·J`.
25#[derive(Debug, Clone, PartialEq)]
26pub enum NoiseModel {
27    /// Identity model — unweighted. Zero-cost: no allocation, no arithmetic.
28    Null,
29    /// Diagonal square-root information (one entry per residual row). Covers
30    /// the block-diagonal Ω that most SLAM graphs use.
31    Diagonal(DVector<f64>),
32    /// Full square-root information matrix (`residual_dim × residual_dim`).
33    Dense(DMatrix<f64>),
34}
35
36/// What [`NoiseModel::from_information_reporting`] had to repair to make an
37/// information matrix PSD.
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub struct InformationRepair {
40    /// Eigen-directions whose negative eigenvalue was clamped to zero. Each
41    /// one is a residual direction the measurement no longer constrains.
42    pub clamped_directions: usize,
43    /// Smallest eigenvalue of the symmetrized `Ω`, before clamping.
44    pub min_eigenvalue: f64,
45    /// Largest eigenvalue of the symmetrized `Ω`, the scale `min_eigenvalue`
46    /// is judged against.
47    pub max_eigenvalue: f64,
48}
49
50/// Relative size below which a negative eigenvalue is attributed to
51/// floating-point noise in the eigendecomposition rather than to bad data.
52const INDEFINITE_TOLERANCE: f64 = 1e-9;
53
54impl InformationRepair {
55    /// True when clamping discarded real information rather than
56    /// floating-point noise.
57    ///
58    /// Rank-deficient `Ω` (an exactly-zero eigenvalue, an unobserved DOF) is
59    /// legitimate and is *not* material — only a negative eigenvalue that is
60    /// large relative to the matrix scale is.
61    pub fn is_material(&self) -> bool {
62        let tol = self.max_eigenvalue.abs() * INDEFINITE_TOLERANCE + 1e-12;
63        self.min_eigenvalue < -tol
64    }
65
66    /// Most negative eigenvalue as a fraction of the largest, a scale-free
67    /// measure of how indefinite `Ω` was. Zero when `Ω` was PSD.
68    pub fn relative_indefiniteness(&self) -> f64 {
69        if self.max_eigenvalue <= 0.0 || self.min_eigenvalue >= 0.0 {
70            return 0.0;
71        }
72        -self.min_eigenvalue / self.max_eigenvalue
73    }
74}
75
76/// Strategy for handling an indefinite per-edge information matrix.
77///
78/// The repair itself lives in [`NoiseModel::from_information_reporting`]; this
79/// enum is the single shared vocabulary for choosing what to do with the
80/// report, so binaries, benches and libraries stop re-implementing the
81/// `clamp | unit-weight` branch with divergent defaults.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
83pub enum RepairStrategy {
84    /// Keep the PSD projection (nearest-PSD clamp). The optimized objective
85    /// stays the Ω-weighted χ², minus the clamped directions.
86    #[default]
87    Clamp,
88    /// Substitute an identity weight for any edge whose `Ω` needed a
89    /// *material* repair. This trades χ² for unweighted cost on those edges —
90    /// callers must label the reported objective accordingly.
91    UnitWeight,
92}
93
94impl RepairStrategy {
95    /// Parse a CLI/env value (`"clamp"` or `"unit-weight"`, case-insensitive).
96    pub fn from_name(name: &str) -> CoreResult<Self> {
97        if name.eq_ignore_ascii_case("clamp") {
98            Ok(RepairStrategy::Clamp)
99        } else if name.eq_ignore_ascii_case("unit-weight") {
100            Ok(RepairStrategy::UnitWeight)
101        } else {
102            Err(CoreError::InvalidInput(format!(
103                "unknown repair strategy {name:?}: expected \"clamp\" or \"unit-weight\""
104            )))
105        }
106    }
107
108    /// Build a noise model from `Ω` under this strategy, reporting what
109    /// happened. Returns the model and its [`InformationRepair`]; under
110    /// [`RepairStrategy::UnitWeight`] a materially-repaired edge comes back
111    /// as [`NoiseModel::Null`] (identity weight).
112    pub fn build(&self, info: DMatrix<f64>) -> CoreResult<(NoiseModel, InformationRepair)> {
113        let (model, repair) = NoiseModel::from_information_reporting(info)?;
114        if *self == RepairStrategy::UnitWeight && repair.is_material() {
115            Ok((NoiseModel::null(), repair))
116        } else {
117            Ok((model, repair))
118        }
119    }
120}
121
122/// Running tally of information-matrix repairs over a loaded graph.
123///
124/// The library reports per edge ([`InformationRepair`]); aggregation belongs
125/// to the caller, and this is the shared counter so every harness reports the
126/// same line. Log one summary per dataset, not one `warn!` per edge.
127#[derive(Debug, Clone, Copy, Default)]
128pub struct RepairSummary {
129    /// Edges examined.
130    pub edges: usize,
131    /// Edges whose `Ω` needed a material repair (real information clamped).
132    pub materially_repaired: usize,
133    /// Of those, edges substituted with identity weight (`UnitWeight` only).
134    pub unit_weighted: usize,
135}
136
137impl RepairSummary {
138    /// Record one edge's outcome under `strategy`.
139    pub fn record(&mut self, strategy: RepairStrategy, repair: &InformationRepair) {
140        self.edges += 1;
141        if repair.is_material() {
142            self.materially_repaired += 1;
143            if strategy == RepairStrategy::UnitWeight {
144                self.unit_weighted += 1;
145            }
146        }
147    }
148
149    /// True when every edge was PSD as parsed.
150    pub fn is_clean(&self) -> bool {
151        self.materially_repaired == 0
152    }
153}
154
155impl NoiseModel {
156    /// Identity model.
157    pub fn null() -> Self {
158        NoiseModel::Null
159    }
160
161    /// Diagonal model from per-component standard deviations
162    /// (`sqrt_info_i = 1/σ_i`).
163    pub fn from_sigmas(sigmas: &[f64]) -> CoreResult<Self> {
164        if sigmas.iter().any(|s| !s.is_finite() || *s <= 0.0) {
165            return Err(CoreError::InvalidInput(
166                "noise model sigmas must be finite and positive".to_string(),
167            ));
168        }
169        Ok(NoiseModel::Diagonal(DVector::from_iterator(
170            sigmas.len(),
171            sigmas.iter().map(|s| 1.0 / s),
172        )))
173    }
174
175    /// Diagonal model from square-root information entries (1/σ per row).
176    pub fn from_diagonal_sqrt_info(sqrt_info: &[f64]) -> CoreResult<Self> {
177        if sqrt_info.iter().any(|s| !s.is_finite() || *s < 0.0) {
178            return Err(CoreError::InvalidInput(
179                "noise model sqrt-information entries must be finite and non-negative".to_string(),
180            ));
181        }
182        Ok(NoiseModel::Diagonal(DVector::from_column_slice(sqrt_info)))
183    }
184
185    /// Dense model from an information matrix `Ω`.
186    ///
187    /// `Ω` must be symmetric PSD. Real g2o graphs frequently carry
188    /// rank-deficient Ω (unobserved DOFs, e.g. zero roll/pitch information),
189    /// so negative eigenvalues are clamped to zero — the corresponding
190    /// residual directions carry no information and whiten to zero. The
191    /// square root is the symmetric `S = V·√Λ⁺·Vᵀ`, satisfying `SᵀS = Ω`.
192    ///
193    /// Clamping is silent here. Callers that must distinguish floating-point
194    /// noise from a genuinely ill-formed Ω — where clamping discards real
195    /// constraints rather than empty directions — should use
196    /// [`Self::from_information_reporting`] and inspect the
197    /// [`InformationRepair`] it returns.
198    pub fn from_information(info: DMatrix<f64>) -> CoreResult<Self> {
199        let (model, repair) = Self::from_information_reporting(info)?;
200        if repair.is_material() {
201            tracing::warn!(
202                "information matrix has a negative eigenvalue (min = {:.3e}, \
203                 max = {:.3e}); clamping to zero — the edge may be ill-formed",
204                repair.min_eigenvalue,
205                repair.max_eigenvalue
206            );
207        }
208        Ok(model)
209    }
210
211    /// Same repair as [`Self::from_information`], but reports what it did.
212    ///
213    /// Clamping an indefinite `Ω` to PSD is the nearest-PSD projection, and on
214    /// a genuinely ill-formed matrix it silently deletes constraints: the
215    /// clamped directions stop influencing the solution at all. Returning the
216    /// [`InformationRepair`] lets a caller decide — keep the repaired `Ω`,
217    /// substitute a unit weight, or reject the measurement — instead of
218    /// optimizing a quietly mutilated objective.
219    pub fn from_information_reporting(info: DMatrix<f64>) -> CoreResult<(Self, InformationRepair)> {
220        let n = info.nrows();
221        if !info.is_square() || n == 0 {
222            return Err(CoreError::InvalidInput(
223                "information matrix must be non-empty and square".to_string(),
224            ));
225        }
226        if !info.iter().all(|v| v.is_finite()) {
227            return Err(CoreError::InvalidInput(
228                "information matrix must be finite".to_string(),
229            ));
230        }
231        let sym = (&info + &info.transpose()) * 0.5;
232        let se = nalgebra::linalg::SymmetricEigen::new(sym);
233        // Negative eigenvalues are clamped to zero (matching g2o/GTSAM, whose
234        // pivoting LDLᵀ tolerates indefinite Ω): fp-noise negatives arise on
235        // rank-deficient directions, and some real datasets carry slightly
236        // indefinite Ω.
237        let lam_max = se.eigenvalues.iter().copied().fold(f64::MIN, f64::max);
238        let lam_min = se.eigenvalues.iter().copied().fold(f64::MAX, f64::min);
239        let mut s = DMatrix::zeros(n, n);
240        let mut clamped_directions = 0;
241        for i in 0..n {
242            let lam = se.eigenvalues[i];
243            if lam <= 0.0 {
244                if lam < 0.0 {
245                    clamped_directions += 1;
246                }
247                continue;
248            }
249            let root = lam.sqrt();
250            for r in 0..n {
251                for c in 0..n {
252                    s[(r, c)] += root * se.eigenvectors[(r, i)] * se.eigenvectors[(c, i)];
253                }
254            }
255        }
256        let repair = InformationRepair {
257            clamped_directions,
258            min_eigenvalue: lam_min,
259            max_eigenvalue: lam_max,
260        };
261        Ok((NoiseModel::Dense(s), repair))
262    }
263
264    /// Dense model from an explicit square-root information matrix `S`
265    /// (callers guarantee `SᵀS` is the intended information matrix).
266    pub fn from_sqrt_info(s: DMatrix<f64>) -> CoreResult<Self> {
267        if !s.is_square() || s.nrows() == 0 || !s.iter().all(|v| v.is_finite()) {
268            return Err(CoreError::InvalidInput(
269                "sqrt-information matrix must be non-empty, square and finite".to_string(),
270            ));
271        }
272        Ok(NoiseModel::Dense(s))
273    }
274
275    /// Residual dimension the model applies to.
276    pub fn dim(&self) -> usize {
277        match self {
278            NoiseModel::Null => 0,
279            NoiseModel::Diagonal(d) => d.len(),
280            NoiseModel::Dense(m) => m.nrows(),
281        }
282    }
283
284    /// Whiten a residual in place: `r ← S·r`.
285    pub fn whiten_residual(&self, residual: &mut [f64]) {
286        match self {
287            NoiseModel::Null => {}
288            NoiseModel::Diagonal(d) => {
289                for (r, s) in residual.iter_mut().zip(d.iter()) {
290                    *r *= s;
291                }
292            }
293            NoiseModel::Dense(m) => {
294                let n = m.nrows();
295                debug_assert_eq!(residual.len(), n);
296                let mut out = vec![0.0; n];
297                for (row, o) in out.iter_mut().enumerate() {
298                    let mut sum = 0.0;
299                    for (c, value) in residual.iter().enumerate() {
300                        sum += m[(row, c)] * value;
301                    }
302                    *o = sum;
303                }
304                residual.copy_from_slice(&out);
305            }
306        }
307    }
308
309    /// Whiten a column-major `(rows × cols)` Jacobian in place: `J ← S·J`.
310    pub fn whiten_jacobian(&self, jacobian: &mut [f64], rows: usize, cols: usize) {
311        match self {
312            NoiseModel::Null => {}
313            NoiseModel::Diagonal(d) => {
314                debug_assert_eq!(d.len(), rows);
315                // Row scaling: row i of every column is multiplied by s_i.
316                for c in 0..cols {
317                    for r in 0..rows {
318                        jacobian[c * rows + r] *= d[r];
319                    }
320                }
321            }
322            NoiseModel::Dense(m) => {
323                debug_assert_eq!(m.nrows(), rows);
324                for c in 0..cols {
325                    let col = jacobian[c * rows..(c + 1) * rows].to_vec();
326                    for (row, o) in jacobian[c * rows..(c + 1) * rows].iter_mut().enumerate() {
327                        let mut sum = 0.0;
328                        for (j, value) in col.iter().enumerate() {
329                            sum += m[(row, j)] * value;
330                        }
331                        *o = sum;
332                    }
333                }
334            }
335        }
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    type TestResult = Result<(), Box<dyn std::error::Error>>;
344
345    #[test]
346    fn repair_strategy_parses_known_names() -> TestResult {
347        assert_eq!(RepairStrategy::from_name("clamp")?, RepairStrategy::Clamp);
348        assert_eq!(
349            RepairStrategy::from_name("UNIT-WEIGHT")?,
350            RepairStrategy::UnitWeight
351        );
352        assert_eq!(RepairStrategy::default(), RepairStrategy::Clamp);
353        assert!(RepairStrategy::from_name("project").is_err());
354        Ok(())
355    }
356
357    #[test]
358    fn clamp_keeps_repaired_model_and_unit_weight_substitutes() -> TestResult {
359        // diag(1, -4): one materially indefinite direction.
360        let info = DMatrix::from_row_slice(2, 2, &[1.0, 0.0, 0.0, -4.0]);
361        let (clamped, repair) = RepairStrategy::Clamp.build(info.clone())?;
362        assert!(repair.is_material());
363        assert!(matches!(clamped, NoiseModel::Dense(_)));
364
365        let (unit, repair) = RepairStrategy::UnitWeight.build(info)?;
366        assert!(repair.is_material());
367        assert_eq!(unit, NoiseModel::Null);
368        Ok(())
369    }
370
371    #[test]
372    fn clean_matrix_never_counts_as_repaired() -> TestResult {
373        let info = DMatrix::from_row_slice(2, 2, &[4.0, 0.0, 0.0, 1.0]);
374        let mut summary = RepairSummary::default();
375        for strategy in [RepairStrategy::Clamp, RepairStrategy::UnitWeight] {
376            let (model, repair) = strategy.build(info.clone())?;
377            summary.record(strategy, &repair);
378            assert!(!matches!(model, NoiseModel::Null));
379        }
380        assert!(summary.is_clean());
381        assert_eq!(summary.edges, 2);
382        assert_eq!(summary.materially_repaired, 0);
383        assert_eq!(summary.unit_weighted, 0);
384        Ok(())
385    }
386
387    #[test]
388    fn summary_counts_material_and_unit_weighted_edges() -> TestResult {
389        let bad = DMatrix::from_row_slice(2, 2, &[1.0, 0.0, 0.0, -4.0]);
390        let mut summary = RepairSummary::default();
391        let (_, repair) = RepairStrategy::Clamp.build(bad.clone())?;
392        summary.record(RepairStrategy::Clamp, &repair);
393        let (_, repair) = RepairStrategy::UnitWeight.build(bad)?;
394        summary.record(RepairStrategy::UnitWeight, &repair);
395        assert_eq!(summary.edges, 2);
396        assert_eq!(summary.materially_repaired, 2);
397        assert_eq!(summary.unit_weighted, 1);
398        assert!(!summary.is_clean());
399        Ok(())
400    }
401}