use nalgebra::{DMatrix, DVector};
use crate::core::CoreError;
use crate::core::CoreResult;
#[derive(Debug, Clone, PartialEq)]
pub enum NoiseModel {
Null,
Diagonal(DVector<f64>),
Dense(DMatrix<f64>),
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct InformationRepair {
pub clamped_directions: usize,
pub min_eigenvalue: f64,
pub max_eigenvalue: f64,
}
const INDEFINITE_TOLERANCE: f64 = 1e-9;
impl InformationRepair {
pub fn is_material(&self) -> bool {
let tol = self.max_eigenvalue.abs() * INDEFINITE_TOLERANCE + 1e-12;
self.min_eigenvalue < -tol
}
pub fn relative_indefiniteness(&self) -> f64 {
if self.max_eigenvalue <= 0.0 || self.min_eigenvalue >= 0.0 {
return 0.0;
}
-self.min_eigenvalue / self.max_eigenvalue
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RepairStrategy {
#[default]
Clamp,
UnitWeight,
}
impl RepairStrategy {
pub fn from_name(name: &str) -> CoreResult<Self> {
if name.eq_ignore_ascii_case("clamp") {
Ok(RepairStrategy::Clamp)
} else if name.eq_ignore_ascii_case("unit-weight") {
Ok(RepairStrategy::UnitWeight)
} else {
Err(CoreError::InvalidInput(format!(
"unknown repair strategy {name:?}: expected \"clamp\" or \"unit-weight\""
)))
}
}
pub fn build(&self, info: DMatrix<f64>) -> CoreResult<(NoiseModel, InformationRepair)> {
let (model, repair) = NoiseModel::from_information_reporting(info)?;
if *self == RepairStrategy::UnitWeight && repair.is_material() {
Ok((NoiseModel::null(), repair))
} else {
Ok((model, repair))
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RepairSummary {
pub edges: usize,
pub materially_repaired: usize,
pub unit_weighted: usize,
}
impl RepairSummary {
pub fn record(&mut self, strategy: RepairStrategy, repair: &InformationRepair) {
self.edges += 1;
if repair.is_material() {
self.materially_repaired += 1;
if strategy == RepairStrategy::UnitWeight {
self.unit_weighted += 1;
}
}
}
pub fn is_clean(&self) -> bool {
self.materially_repaired == 0
}
}
impl NoiseModel {
pub fn null() -> Self {
NoiseModel::Null
}
pub fn from_sigmas(sigmas: &[f64]) -> CoreResult<Self> {
if sigmas.iter().any(|s| !s.is_finite() || *s <= 0.0) {
return Err(CoreError::InvalidInput(
"noise model sigmas must be finite and positive".to_string(),
));
}
Ok(NoiseModel::Diagonal(DVector::from_iterator(
sigmas.len(),
sigmas.iter().map(|s| 1.0 / s),
)))
}
pub fn from_diagonal_sqrt_info(sqrt_info: &[f64]) -> CoreResult<Self> {
if sqrt_info.iter().any(|s| !s.is_finite() || *s < 0.0) {
return Err(CoreError::InvalidInput(
"noise model sqrt-information entries must be finite and non-negative".to_string(),
));
}
Ok(NoiseModel::Diagonal(DVector::from_column_slice(sqrt_info)))
}
pub fn from_information(info: DMatrix<f64>) -> CoreResult<Self> {
let (model, repair) = Self::from_information_reporting(info)?;
if repair.is_material() {
tracing::warn!(
"information matrix has a negative eigenvalue (min = {:.3e}, \
max = {:.3e}); clamping to zero — the edge may be ill-formed",
repair.min_eigenvalue,
repair.max_eigenvalue
);
}
Ok(model)
}
pub fn from_information_reporting(info: DMatrix<f64>) -> CoreResult<(Self, InformationRepair)> {
let n = info.nrows();
if !info.is_square() || n == 0 {
return Err(CoreError::InvalidInput(
"information matrix must be non-empty and square".to_string(),
));
}
if !info.iter().all(|v| v.is_finite()) {
return Err(CoreError::InvalidInput(
"information matrix must be finite".to_string(),
));
}
let sym = (&info + &info.transpose()) * 0.5;
let se = nalgebra::linalg::SymmetricEigen::new(sym);
let lam_max = se.eigenvalues.iter().copied().fold(f64::MIN, f64::max);
let lam_min = se.eigenvalues.iter().copied().fold(f64::MAX, f64::min);
let mut s = DMatrix::zeros(n, n);
let mut clamped_directions = 0;
for i in 0..n {
let lam = se.eigenvalues[i];
if lam <= 0.0 {
if lam < 0.0 {
clamped_directions += 1;
}
continue;
}
let root = lam.sqrt();
for r in 0..n {
for c in 0..n {
s[(r, c)] += root * se.eigenvectors[(r, i)] * se.eigenvectors[(c, i)];
}
}
}
let repair = InformationRepair {
clamped_directions,
min_eigenvalue: lam_min,
max_eigenvalue: lam_max,
};
Ok((NoiseModel::Dense(s), repair))
}
pub fn from_sqrt_info(s: DMatrix<f64>) -> CoreResult<Self> {
if !s.is_square() || s.nrows() == 0 || !s.iter().all(|v| v.is_finite()) {
return Err(CoreError::InvalidInput(
"sqrt-information matrix must be non-empty, square and finite".to_string(),
));
}
Ok(NoiseModel::Dense(s))
}
pub fn dim(&self) -> usize {
match self {
NoiseModel::Null => 0,
NoiseModel::Diagonal(d) => d.len(),
NoiseModel::Dense(m) => m.nrows(),
}
}
pub fn whiten_residual(&self, residual: &mut [f64]) {
match self {
NoiseModel::Null => {}
NoiseModel::Diagonal(d) => {
for (r, s) in residual.iter_mut().zip(d.iter()) {
*r *= s;
}
}
NoiseModel::Dense(m) => {
let n = m.nrows();
debug_assert_eq!(residual.len(), n);
let mut out = vec![0.0; n];
for (row, o) in out.iter_mut().enumerate() {
let mut sum = 0.0;
for (c, value) in residual.iter().enumerate() {
sum += m[(row, c)] * value;
}
*o = sum;
}
residual.copy_from_slice(&out);
}
}
}
pub fn whiten_jacobian(&self, jacobian: &mut [f64], rows: usize, cols: usize) {
match self {
NoiseModel::Null => {}
NoiseModel::Diagonal(d) => {
debug_assert_eq!(d.len(), rows);
for c in 0..cols {
for r in 0..rows {
jacobian[c * rows + r] *= d[r];
}
}
}
NoiseModel::Dense(m) => {
debug_assert_eq!(m.nrows(), rows);
for c in 0..cols {
let col = jacobian[c * rows..(c + 1) * rows].to_vec();
for (row, o) in jacobian[c * rows..(c + 1) * rows].iter_mut().enumerate() {
let mut sum = 0.0;
for (j, value) in col.iter().enumerate() {
sum += m[(row, j)] * value;
}
*o = sum;
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
type TestResult = Result<(), Box<dyn std::error::Error>>;
#[test]
fn repair_strategy_parses_known_names() -> TestResult {
assert_eq!(RepairStrategy::from_name("clamp")?, RepairStrategy::Clamp);
assert_eq!(
RepairStrategy::from_name("UNIT-WEIGHT")?,
RepairStrategy::UnitWeight
);
assert_eq!(RepairStrategy::default(), RepairStrategy::Clamp);
assert!(RepairStrategy::from_name("project").is_err());
Ok(())
}
#[test]
fn clamp_keeps_repaired_model_and_unit_weight_substitutes() -> TestResult {
let info = DMatrix::from_row_slice(2, 2, &[1.0, 0.0, 0.0, -4.0]);
let (clamped, repair) = RepairStrategy::Clamp.build(info.clone())?;
assert!(repair.is_material());
assert!(matches!(clamped, NoiseModel::Dense(_)));
let (unit, repair) = RepairStrategy::UnitWeight.build(info)?;
assert!(repair.is_material());
assert_eq!(unit, NoiseModel::Null);
Ok(())
}
#[test]
fn clean_matrix_never_counts_as_repaired() -> TestResult {
let info = DMatrix::from_row_slice(2, 2, &[4.0, 0.0, 0.0, 1.0]);
let mut summary = RepairSummary::default();
for strategy in [RepairStrategy::Clamp, RepairStrategy::UnitWeight] {
let (model, repair) = strategy.build(info.clone())?;
summary.record(strategy, &repair);
assert!(!matches!(model, NoiseModel::Null));
}
assert!(summary.is_clean());
assert_eq!(summary.edges, 2);
assert_eq!(summary.materially_repaired, 0);
assert_eq!(summary.unit_weighted, 0);
Ok(())
}
#[test]
fn summary_counts_material_and_unit_weighted_edges() -> TestResult {
let bad = DMatrix::from_row_slice(2, 2, &[1.0, 0.0, 0.0, -4.0]);
let mut summary = RepairSummary::default();
let (_, repair) = RepairStrategy::Clamp.build(bad.clone())?;
summary.record(RepairStrategy::Clamp, &repair);
let (_, repair) = RepairStrategy::UnitWeight.build(bad)?;
summary.record(RepairStrategy::UnitWeight, &repair);
assert_eq!(summary.edges, 2);
assert_eq!(summary.materially_repaired, 2);
assert_eq!(summary.unit_weighted, 1);
assert!(!summary.is_clean());
Ok(())
}
}