1use nalgebra::{DMatrix, DVector};
18
19use crate::core::CoreError;
20use crate::core::CoreResult;
21
22#[derive(Debug, Clone, PartialEq)]
26pub enum NoiseModel {
27 Null,
29 Diagonal(DVector<f64>),
32 Dense(DMatrix<f64>),
34}
35
36#[derive(Debug, Clone, Copy, PartialEq)]
39pub struct InformationRepair {
40 pub clamped_directions: usize,
43 pub min_eigenvalue: f64,
45 pub max_eigenvalue: f64,
48}
49
50const INDEFINITE_TOLERANCE: f64 = 1e-9;
53
54impl InformationRepair {
55 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
83pub enum RepairStrategy {
84 #[default]
87 Clamp,
88 UnitWeight,
92}
93
94impl RepairStrategy {
95 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 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#[derive(Debug, Clone, Copy, Default)]
128pub struct RepairSummary {
129 pub edges: usize,
131 pub materially_repaired: usize,
133 pub unit_weighted: usize,
135}
136
137impl RepairSummary {
138 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 pub fn is_clean(&self) -> bool {
151 self.materially_repaired == 0
152 }
153}
154
155impl NoiseModel {
156 pub fn null() -> Self {
158 NoiseModel::Null
159 }
160
161 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 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 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 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 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 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 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 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 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 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 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}