use std::{
fmt::{Display, Formatter},
ops::{Add, Mul},
};
use crate::coordinates::{cov3::Cov3, equatorial::EquCoord, gnomonic_projection::TangentVec};
#[derive(Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Cov2 {
pub xx: f64,
pub yy: f64,
pub xy: f64,
}
impl Cov2 {
#[inline]
pub fn from_equ(c: &EquCoord) -> Self {
Self::diag(c.ra_error * c.ra_error, c.dec_error * c.dec_error)
}
#[inline]
pub fn diag(var_x: f64, var_y: f64) -> Self {
Self {
xx: var_x,
yy: var_y,
xy: 0.0,
}
}
#[inline]
pub fn det(&self) -> f64 {
self.xx * self.yy - self.xy * self.xy
}
#[inline]
pub fn trace(&self) -> f64 {
self.xx + self.yy
}
#[inline]
pub fn inverse(&self) -> Option<Self> {
let d = self.det();
if d.abs() < f64::EPSILON {
return None;
}
let inv = 1.0 / d;
Some(Self {
xx: self.yy * inv,
yy: self.xx * inv,
xy: -self.xy * inv,
})
}
#[inline]
pub fn mahalanobis_sq(&self, v: TangentVec) -> Option<f64> {
let inv = self.inverse()?;
Some(inv.xx * v.dx * v.dx + 2.0 * inv.xy * v.dx * v.dy + inv.yy * v.dy * v.dy)
}
#[inline]
pub fn zero() -> Self {
Self {
xx: 0.0,
yy: 0.0,
xy: 0.0,
}
}
#[inline]
pub fn isotropic(q: f64) -> Self {
Self::diag(q, q)
}
#[inline]
pub fn quad_form(&self, v: TangentVec) -> f64 {
v.dot(*self * v)
}
#[inline]
pub fn inflate_isotropic(self, q: f64) -> Self {
Self {
xx: self.xx + q,
yy: self.yy + q,
xy: self.xy,
}
}
#[inline]
pub fn lambda_max(&self) -> f64 {
let half_tr = 0.5 * self.trace();
let half_diff = 0.5 * (self.xx - self.yy);
let disc = (half_diff * half_diff + self.xy * self.xy).sqrt();
half_tr + disc
}
#[inline]
pub fn lambda_min(&self) -> f64 {
let half_tr = 0.5 * self.trace();
let half_diff = 0.5 * (self.xx - self.yy);
let disc = (half_diff * half_diff + self.xy * self.xy).sqrt();
half_tr - disc
}
#[inline]
pub fn transform_j3(&self, j: [[f64; 2]; 3]) -> Cov3 {
#[inline]
fn row_quad(a: [f64; 2], b: [f64; 2], xx: f64, yy: f64, xy: f64) -> f64 {
a[0] * b[0] * xx + (a[0] * b[1] + a[1] * b[0]) * xy + a[1] * b[1] * yy
}
Cov3 {
xx: row_quad(j[0], j[0], self.xx, self.yy, self.xy),
yy: row_quad(j[1], j[1], self.xx, self.yy, self.xy),
zz: row_quad(j[2], j[2], self.xx, self.yy, self.xy),
xy: row_quad(j[0], j[1], self.xx, self.yy, self.xy),
xz: row_quad(j[0], j[2], self.xx, self.yy, self.xy),
yz: row_quad(j[1], j[2], self.xx, self.yy, self.xy),
}
}
#[inline]
pub fn cholesky_lower(&self, floor: f64) -> Option<CholeskyLower2> {
if !self.xx.is_finite() || !self.yy.is_finite() || !self.xy.is_finite() {
return None;
}
if !floor.is_finite() || floor <= 0.0 {
return None;
}
let a = self.xx.max(floor);
let d = self.yy.max(floor);
let b = self.xy;
let l00 = a.sqrt();
if !l00.is_finite() || l00 <= 0.0 {
return None;
}
let l10 = b / l00;
let t = d - l10 * l10;
if !t.is_finite() || t < floor {
return None;
}
let l11 = t.sqrt();
if !l11.is_finite() || l11 <= 0.0 {
return None;
}
Some(CholeskyLower2 { l00, l10, l11 })
}
#[inline]
pub fn whiten_cholesky(&self, v: TangentVec, floor: f64) -> Option<(TangentVec, f64)> {
let l = self.cholesky_lower(floor)?;
let z = l.solve_forward(v);
if !z.dx.is_finite() || !z.dy.is_finite() {
return None;
}
let z_sq = (z.dx * z.dx + z.dy * z.dy).max(0.0);
Some((z, z_sq))
}
#[inline]
pub fn whiten_diag(&self, v: TangentVec, floor: f64) -> TangentVec {
let sx = self.xx.max(floor).sqrt();
let sy = self.yy.max(floor).sqrt();
let finite_or_zero = |x: f64| if x.is_finite() { x } else { 0.0 };
TangentVec {
dx: finite_or_zero(v.dx / sx),
dy: finite_or_zero(v.dy / sy),
}
}
}
impl Add for Cov2 {
type Output = Self;
#[inline]
fn add(self, rhs: Self) -> Self::Output {
Self {
xx: self.xx + rhs.xx,
yy: self.yy + rhs.yy,
xy: self.xy + rhs.xy,
}
}
}
impl Add for &Cov2 {
type Output = Cov2;
#[inline]
fn add(self, rhs: Self) -> Self::Output {
*self + *rhs
}
}
impl Mul<f64> for Cov2 {
type Output = Self;
#[inline]
fn mul(self, rhs: f64) -> Self::Output {
Self {
xx: self.xx * rhs,
yy: self.yy * rhs,
xy: self.xy * rhs,
}
}
}
impl Mul<f64> for &Cov2 {
type Output = Cov2;
#[inline]
fn mul(self, rhs: f64) -> Self::Output {
*self * rhs
}
}
impl Display for Cov2 {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
writeln!(f, "Cov2 {{")?;
writeln!(f, " xx : {:.6e}", self.xx)?;
writeln!(f, " yy : {:.6e}", self.yy)?;
writeln!(f, " xy : {:.6e}", self.xy)?;
write!(f, "}}")
}
}
#[derive(Debug, Clone, Copy)]
pub struct CholeskyLower2 {
pub l00: f64,
pub l10: f64,
pub l11: f64,
}
impl CholeskyLower2 {
#[inline]
pub fn solve_forward(&self, v: TangentVec) -> TangentVec {
let vx = v.dx;
let vy = v.dy;
let z1 = vx / self.l00;
let z2 = (vy - self.l10 * z1) / self.l11;
TangentVec { dx: z1, dy: z2 }
}
}
#[cfg(test)]
mod cov2_tests {
use super::*;
use approx::assert_abs_diff_eq;
use proptest::prelude::*;
const EPS: f64 = 1e-12;
#[test]
fn diag_sets_xy_to_zero() {
let c = Cov2::diag(4.0, 9.0);
assert_abs_diff_eq!(c.xx, 4.0, epsilon = EPS);
assert_abs_diff_eq!(c.yy, 9.0, epsilon = EPS);
assert_abs_diff_eq!(c.xy, 0.0, epsilon = EPS);
}
#[test]
fn zero_all_entries_zero() {
let c = Cov2::zero();
assert_eq!(c.xx, 0.0);
assert_eq!(c.yy, 0.0);
assert_eq!(c.xy, 0.0);
}
#[test]
fn isotropic_equals_diag_q_q() {
let q = 3.7_f64;
let iso = Cov2::isotropic(q);
let diag = Cov2::diag(q, q);
assert_eq!(iso, diag);
}
#[test]
fn from_equ_uses_squared_errors() {
use crate::coordinates::equatorial::EquCoord;
let c = EquCoord::new(0.0, 0.01, 0.0, 0.02);
let cov = Cov2::from_equ(&c);
assert_abs_diff_eq!(cov.xx, 0.01_f64 * 0.01, epsilon = EPS);
assert_abs_diff_eq!(cov.yy, 0.02_f64 * 0.02, epsilon = EPS);
assert_abs_diff_eq!(cov.xy, 0.0, epsilon = EPS);
}
#[test]
fn det_diagonal() {
let c = Cov2::diag(3.0, 5.0);
assert_abs_diff_eq!(c.det(), 15.0, epsilon = EPS);
}
#[test]
fn det_full() {
let c = Cov2 {
xx: 4.0,
yy: 9.0,
xy: 2.0,
};
assert_abs_diff_eq!(c.det(), 32.0, epsilon = EPS);
}
#[test]
fn trace_sum_of_diagonal() {
let c = Cov2 {
xx: 4.0,
yy: 9.0,
xy: 2.0,
};
assert_abs_diff_eq!(c.trace(), 13.0, epsilon = EPS);
}
#[test]
fn inverse_diagonal() {
let c = Cov2::diag(4.0, 9.0);
let inv = c.inverse().expect("invertible");
assert_abs_diff_eq!(inv.xx, 1.0 / 4.0, epsilon = EPS);
assert_abs_diff_eq!(inv.yy, 1.0 / 9.0, epsilon = EPS);
assert_abs_diff_eq!(inv.xy, 0.0, epsilon = EPS);
}
#[test]
fn inverse_full() {
let c = Cov2 {
xx: 4.0,
yy: 9.0,
xy: 2.0,
};
let inv = c.inverse().expect("invertible");
let a00 = c.xx * inv.xx + c.xy * inv.xy;
let a01 = c.xx * inv.xy + c.xy * inv.yy;
let a10 = c.xy * inv.xx + c.yy * inv.xy;
let a11 = c.xy * inv.xy + c.yy * inv.yy;
assert_abs_diff_eq!(a00, 1.0, epsilon = 1e-10);
assert_abs_diff_eq!(a01, 0.0, epsilon = 1e-10);
assert_abs_diff_eq!(a10, 0.0, epsilon = 1e-10);
assert_abs_diff_eq!(a11, 1.0, epsilon = 1e-10);
}
#[test]
fn inverse_singular_returns_none() {
let c = Cov2 {
xx: 1.0,
yy: 1.0,
xy: 1.0,
};
assert!(c.inverse().is_none());
}
#[test]
fn mahalanobis_sq_isotropic() {
let q = 4.0_f64;
let c = Cov2::isotropic(q);
let v = TangentVec { dx: 3.0, dy: 4.0 };
let got = c.mahalanobis_sq(v).unwrap();
let expected = (3.0_f64 * 3.0 + 4.0 * 4.0) / q;
assert_abs_diff_eq!(got, expected, epsilon = 1e-10);
}
#[test]
fn mahalanobis_sq_singular_returns_none() {
let c = Cov2 {
xx: 1.0,
yy: 1.0,
xy: 1.0,
};
assert!(c.mahalanobis_sq(TangentVec { dx: 1.0, dy: 0.0 }).is_none());
}
#[test]
fn inflate_isotropic_adds_to_diagonal_only() {
let c = Cov2 {
xx: 1.0,
yy: 2.0,
xy: 0.5,
};
let inflated = c.inflate_isotropic(3.0);
assert_abs_diff_eq!(inflated.xx, 4.0, epsilon = EPS);
assert_abs_diff_eq!(inflated.yy, 5.0, epsilon = EPS);
assert_abs_diff_eq!(inflated.xy, 0.5, epsilon = EPS);
}
#[test]
fn lambda_max_min_diagonal() {
let c = Cov2::diag(3.0, 7.0);
assert_abs_diff_eq!(c.lambda_max(), 7.0, epsilon = EPS);
assert_abs_diff_eq!(c.lambda_min(), 3.0, epsilon = EPS);
}
#[test]
fn lambda_max_min_isotropic() {
let c = Cov2::isotropic(5.0);
assert_abs_diff_eq!(c.lambda_max(), 5.0, epsilon = EPS);
assert_abs_diff_eq!(c.lambda_min(), 5.0, epsilon = EPS);
}
#[test]
fn lambda_max_gte_lambda_min() {
let c = Cov2 {
xx: 4.0,
yy: 9.0,
xy: 2.0,
};
assert!(c.lambda_max() >= c.lambda_min());
}
#[test]
fn eigenvalues_sum_equals_trace() {
let c = Cov2 {
xx: 4.0,
yy: 9.0,
xy: 2.0,
};
assert_abs_diff_eq!(c.lambda_max() + c.lambda_min(), c.trace(), epsilon = EPS);
}
#[test]
fn eigenvalues_product_equals_det() {
let c = Cov2 {
xx: 4.0,
yy: 9.0,
xy: 2.0,
};
assert_abs_diff_eq!(c.lambda_max() * c.lambda_min(), c.det(), epsilon = 1e-10);
}
#[test]
fn add_combines_entries() {
let a = Cov2 {
xx: 1.0,
yy: 2.0,
xy: 3.0,
};
let b = Cov2 {
xx: 4.0,
yy: 5.0,
xy: 6.0,
};
let s = a + b;
assert_abs_diff_eq!(s.xx, 5.0, epsilon = EPS);
assert_abs_diff_eq!(s.yy, 7.0, epsilon = EPS);
assert_abs_diff_eq!(s.xy, 9.0, epsilon = EPS);
}
#[test]
fn mul_scales_all_entries() {
let c = Cov2 {
xx: 1.0,
yy: 2.0,
xy: 3.0,
};
let s = c * 2.0;
assert_abs_diff_eq!(s.xx, 2.0, epsilon = EPS);
assert_abs_diff_eq!(s.yy, 4.0, epsilon = EPS);
assert_abs_diff_eq!(s.xy, 6.0, epsilon = EPS);
}
#[test]
fn display_contains_entries() {
let c = Cov2::diag(1.0, 2.0);
let s = format!("{}", c);
assert!(s.contains("xx"));
assert!(s.contains("yy"));
assert!(s.contains("xy"));
}
#[test]
fn transform_j3_identity_gives_embedded_cov2() {
let cov = Cov2 {
xx: 4.0,
yy: 9.0,
xy: 2.0,
};
let j = [[1.0, 0.0], [0.0, 1.0], [0.0, 0.0]];
let out = cov.transform_j3(j);
assert_abs_diff_eq!(out.xx, cov.xx, epsilon = EPS);
assert_abs_diff_eq!(out.yy, cov.yy, epsilon = EPS);
assert_abs_diff_eq!(out.zz, 0.0, epsilon = EPS);
assert_abs_diff_eq!(out.xy, cov.xy, epsilon = EPS);
assert_abs_diff_eq!(out.xz, 0.0, epsilon = EPS);
assert_abs_diff_eq!(out.yz, 0.0, epsilon = EPS);
}
#[test]
fn transform_j3_zero_cov_gives_zero_output() {
let cov = Cov2::zero();
let j = [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]];
let out = cov.transform_j3(j);
assert_eq!(out.xx, 0.0);
assert_eq!(out.yy, 0.0);
assert_eq!(out.zz, 0.0);
assert_eq!(out.xy, 0.0);
assert_eq!(out.xz, 0.0);
assert_eq!(out.yz, 0.0);
}
#[test]
fn transform_j3_diagonal_input_matches_manual() {
let cov = Cov2::diag(4.0, 9.0);
let j = [[1.0, 1.0], [1.0, -1.0], [0.0, 1.0]];
let out = cov.transform_j3(j);
assert_abs_diff_eq!(out.xx, 13.0, epsilon = EPS);
assert_abs_diff_eq!(out.yy, 13.0, epsilon = EPS);
assert_abs_diff_eq!(out.zz, 9.0, epsilon = EPS);
assert_abs_diff_eq!(out.xy, -5.0, epsilon = EPS);
assert_abs_diff_eq!(out.xz, 9.0, epsilon = EPS);
assert_abs_diff_eq!(out.yz, -9.0, epsilon = EPS);
}
#[test]
fn transform_j3_output_is_symmetric() {
let cov = Cov2 {
xx: 3.0,
yy: 7.0,
xy: 1.5,
};
let j = [[0.5, -1.0], [0.2, 0.8], [-0.3, 0.1]];
let out = cov.transform_j3(j);
let xy_check = {
let a = j[0];
let b = j[1];
a[0] * b[0] * cov.xx + (a[0] * b[1] + a[1] * b[0]) * cov.xy + a[1] * b[1] * cov.yy
};
assert_abs_diff_eq!(out.xy, xy_check, epsilon = EPS);
}
#[test]
fn transform_j3_then_transform_j2_round_trips() {
let cov = Cov2 {
xx: 5.0,
yy: 3.0,
xy: 1.0,
};
let j = [[1.0, 0.0], [0.0, 1.0], [0.0, 0.0]];
let k = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
let cov3 = cov.transform_j3(j);
let recovered = cov3.transform_j2(k);
assert_abs_diff_eq!(recovered.xx, cov.xx, epsilon = EPS);
assert_abs_diff_eq!(recovered.yy, cov.yy, epsilon = EPS);
assert_abs_diff_eq!(recovered.xy, cov.xy, epsilon = EPS);
}
prop_compose! {
fn psd_cov2()(a in 0.01_f64..10.0, b in -5.0_f64..5.0, c in 0.01_f64..10.0)
-> Cov2
{
Cov2 { xx: a * a, yy: b * b + c * c, xy: a * b }
}
}
proptest! {
#[test]
fn det_nonneg_for_psd(cov in psd_cov2()) {
prop_assert!(cov.det() >= -1e-12);
}
#[test]
fn lambda_ordering_for_psd(cov in psd_cov2()) {
prop_assert!(cov.lambda_max() >= cov.lambda_min() - 1e-12);
}
#[test]
fn trace_equals_eigenvalue_sum(cov in psd_cov2()) {
prop_assert!((cov.trace() - (cov.lambda_max() + cov.lambda_min())).abs() < 1e-10);
}
#[test]
fn det_equals_eigenvalue_product(cov in psd_cov2()) {
prop_assert!((cov.det() - cov.lambda_max() * cov.lambda_min()).abs() < 1e-10);
}
#[test]
fn inflate_increases_lambda_max(cov in psd_cov2(), q in 0.0_f64..10.0) {
prop_assert!(cov.inflate_isotropic(q).lambda_max() >= cov.lambda_max() - 1e-12);
}
#[test]
fn inverse_correctness(cov in psd_cov2()) {
if let Some(inv) = cov.inverse() {
let a00 = cov.xx * inv.xx + cov.xy * inv.xy;
let a11 = cov.xy * inv.xy + cov.yy * inv.yy;
prop_assert!((a00 - 1.0).abs() < 1e-8);
prop_assert!((a11 - 1.0).abs() < 1e-8);
}
}
#[test]
fn mahalanobis_sq_nonneg(cov in psd_cov2(), vx in -10.0_f64..10.0, vy in -10.0_f64..10.0) {
let v = TangentVec { dx: vx, dy: vy };
if let Some(m) = cov.mahalanobis_sq(v) {
prop_assert!(m >= -1e-10);
}
}
#[test]
fn transform_j3_output_diagonal_nonneg(
cov in psd_cov2(),
j00 in -3.0_f64..3.0, j01 in -3.0_f64..3.0,
j10 in -3.0_f64..3.0, j11 in -3.0_f64..3.0,
j20 in -3.0_f64..3.0, j21 in -3.0_f64..3.0,
) {
let j = [[j00, j01], [j10, j11], [j20, j21]];
let out = cov.transform_j3(j);
prop_assert!(out.xx >= -1e-10, "out.xx negative: {}", out.xx);
prop_assert!(out.yy >= -1e-10, "out.yy negative: {}", out.yy);
prop_assert!(out.zz >= -1e-10, "out.zz negative: {}", out.zz);
}
}
#[test]
fn quad_form_isotropic() {
let q = 3.0_f64;
let m = Cov2::isotropic(q);
let v = TangentVec { dx: 3.0, dy: 4.0 };
assert_abs_diff_eq!(m.quad_form(v), q * 25.0, epsilon = EPS);
}
#[test]
fn quad_form_zero_cov_is_zero() {
let m = Cov2::zero();
let v = TangentVec { dx: 5.0, dy: 7.0 };
assert_abs_diff_eq!(m.quad_form(v), 0.0, epsilon = EPS);
}
#[test]
fn quad_form_full() {
let m = Cov2 {
xx: 4.0,
yy: 9.0,
xy: 2.0,
};
let v = TangentVec { dx: 1.0, dy: 1.0 };
assert_abs_diff_eq!(m.quad_form(v), 17.0, epsilon = EPS);
}
#[test]
fn cholesky_lower_diagonal() {
let c = Cov2::diag(4.0, 9.0);
let l = c.cholesky_lower(1e-20).expect("SPD diagonal");
assert_abs_diff_eq!(l.l00, 2.0, epsilon = EPS);
assert_abs_diff_eq!(l.l10, 0.0, epsilon = EPS);
assert_abs_diff_eq!(l.l11, 3.0, epsilon = EPS);
}
#[test]
fn cholesky_lower_reconstructs_cov() {
let c = Cov2 {
xx: 4.0,
yy: 5.0,
xy: 2.0,
};
let l = c.cholesky_lower(1e-20).expect("SPD");
assert_abs_diff_eq!(l.l00 * l.l00, c.xx, epsilon = 1e-10);
assert_abs_diff_eq!(l.l10 * l.l10 + l.l11 * l.l11, c.yy, epsilon = 1e-10);
assert_abs_diff_eq!(l.l00 * l.l10, c.xy, epsilon = 1e-10);
}
#[test]
fn cholesky_lower_singular_returns_none() {
let c = Cov2 {
xx: 1.0,
yy: 1.0,
xy: 1.0,
};
assert!(c.cholesky_lower(1e-20).is_none());
}
#[test]
fn cholesky_lower_nonfinite_returns_none() {
let c = Cov2 {
xx: f64::NAN,
yy: 1.0,
xy: 0.0,
};
assert!(c.cholesky_lower(1e-20).is_none());
}
#[test]
fn cholesky_lower_zero_floor_returns_none() {
let c = Cov2::isotropic(1.0);
assert!(c.cholesky_lower(0.0).is_none());
}
#[test]
fn cholesky_lower_negative_floor_returns_none() {
let c = Cov2::isotropic(1.0);
assert!(c.cholesky_lower(-1e-10).is_none());
}
#[test]
fn solve_forward_diagonal() {
let c = Cov2::diag(4.0, 9.0);
let l = c.cholesky_lower(1e-20).unwrap();
let v = TangentVec { dx: 6.0, dy: 9.0 };
let z = l.solve_forward(v);
assert_abs_diff_eq!(z.dx, 3.0, epsilon = EPS);
assert_abs_diff_eq!(z.dy, 3.0, epsilon = EPS);
}
#[test]
fn solve_forward_inverts_cholesky() {
let c = Cov2 {
xx: 4.0,
yy: 5.0,
xy: 2.0,
};
let l = c.cholesky_lower(1e-20).unwrap();
let v = TangentVec { dx: 1.0, dy: 2.0 };
let z = l.solve_forward(v);
let lz_x = l.l00 * z.dx;
let lz_y = l.l10 * z.dx + l.l11 * z.dy;
assert_abs_diff_eq!(lz_x, v.dx, epsilon = 1e-10);
assert_abs_diff_eq!(lz_y, v.dy, epsilon = 1e-10);
}
#[test]
fn whiten_cholesky_isotropic() {
let q = 4.0_f64;
let c = Cov2::isotropic(q);
let v = TangentVec { dx: 2.0, dy: 0.0 };
let (z, z_sq) = c.whiten_cholesky(v, 1e-20).expect("SPD");
assert_abs_diff_eq!(z.dx, 1.0, epsilon = 1e-10);
assert_abs_diff_eq!(z.dy, 0.0, epsilon = 1e-10);
assert_abs_diff_eq!(z_sq, 1.0, epsilon = 1e-10);
}
#[test]
fn whiten_cholesky_z_sq_equals_mahalanobis() {
let c = Cov2 {
xx: 4.0,
yy: 9.0,
xy: 2.0,
};
let v = TangentVec { dx: 1.0, dy: 2.0 };
let (_, z_sq) = c.whiten_cholesky(v, 1e-20).unwrap();
let mah = c.mahalanobis_sq(v).unwrap();
assert_abs_diff_eq!(z_sq, mah, epsilon = 1e-8);
}
#[test]
fn whiten_cholesky_singular_returns_none() {
let c = Cov2 {
xx: 1.0,
yy: 1.0,
xy: 1.0,
};
assert!(
c.whiten_cholesky(TangentVec { dx: 1.0, dy: 0.0 }, 1e-20)
.is_none()
);
}
#[test]
fn whiten_cholesky_z_sq_nonneg() {
let c = Cov2::isotropic(1.0);
let v = TangentVec { dx: -3.0, dy: -4.0 };
let (_, z_sq) = c.whiten_cholesky(v, 1e-20).unwrap();
assert!(z_sq >= 0.0);
}
#[test]
fn whiten_diag_isotropic() {
let q = 9.0_f64;
let c = Cov2::isotropic(q);
let v = TangentVec { dx: 3.0, dy: 6.0 };
let z = c.whiten_diag(v, 1e-20);
assert_abs_diff_eq!(z.dx, 1.0, epsilon = 1e-10);
assert_abs_diff_eq!(z.dy, 2.0, epsilon = 1e-10);
}
#[test]
fn whiten_diag_independent_axes() {
let c = Cov2 {
xx: 4.0,
yy: 16.0,
xy: 100.0,
};
let v = TangentVec { dx: 2.0, dy: 4.0 };
let z = c.whiten_diag(v, 1e-20);
assert_abs_diff_eq!(z.dx, 1.0, epsilon = 1e-10); assert_abs_diff_eq!(z.dy, 1.0, epsilon = 1e-10); }
#[test]
fn whiten_diag_zero_variance_uses_floor() {
let c = Cov2::zero();
let v = TangentVec { dx: 1.0, dy: 1.0 };
let z = c.whiten_diag(v, 1.0);
assert!(z.dx.is_finite());
assert!(z.dy.is_finite());
}
proptest! {
#[test]
fn cholesky_reconstructs_psd(cov in psd_cov2()) {
let l = cov.cholesky_lower(1e-12).expect("PSD should decompose");
let xx = l.l00 * l.l00;
let xy = l.l10 * l.l00;
let yy = l.l10 * l.l10 + l.l11 * l.l11;
prop_assert!((xx - cov.xx).abs() < 1e-8, "xx mismatch");
prop_assert!((xy - cov.xy).abs() < 1e-8, "xy mismatch");
prop_assert!((yy - cov.yy).abs() < 1e-8, "yy mismatch");
}
#[test]
fn cholesky_diagonal_positive(cov in psd_cov2()) {
let l = cov.cholesky_lower(1e-12).expect("PSD should decompose");
prop_assert!(l.l00 > 0.0, "l00 non-positive");
prop_assert!(l.l11 > 0.0, "l11 non-positive");
}
#[test]
fn proptest_whiten_cholesky_z_sq_equals_mahalanobis(
cov in psd_cov2(),
vx in -5.0_f64..5.0,
vy in -5.0_f64..5.0,
) {
let v = TangentVec { dx: vx, dy: vy };
if let Some((_z, z_sq)) = cov.whiten_cholesky(v, 1e-12)
&& let Some(mah) = cov.mahalanobis_sq(v) {
let tol = 1e-8 * z_sq.abs().max(mah.abs()).max(1.0);
prop_assert!((z_sq - mah).abs() < tol, "z² {} ≠ mah {}", z_sq, mah);
}
}
#[test]
fn proptest_whiten_cholesky_z_sq_nonneg(
cov in psd_cov2(),
vx in -5.0_f64..5.0,
vy in -5.0_f64..5.0,
) {
let v = TangentVec { dx: vx, dy: vy };
if let Some((_z, z_sq)) = cov.whiten_cholesky(v, 1e-12) {
prop_assert!(z_sq >= -1e-10);
}
}
#[test]
fn whiten_diag_finite_for_psd(
cov in psd_cov2(),
vx in -5.0_f64..5.0,
vy in -5.0_f64..5.0,
) {
let v = TangentVec { dx: vx, dy: vy };
let z = cov.whiten_diag(v, 1e-20);
prop_assert!(z.dx.is_finite(), "z.dx is not finite");
prop_assert!(z.dy.is_finite(), "z.dy is not finite");
}
#[test]
fn quad_form_nonneg_for_psd(
cov in psd_cov2(),
vx in -10.0_f64..10.0,
vy in -10.0_f64..10.0,
) {
let v = TangentVec { dx: vx, dy: vy };
prop_assert!(cov.quad_form(v) >= -1e-10);
}
#[test]
fn quad_form_homogeneous(
cov in psd_cov2(),
vx in -5.0_f64..5.0,
vy in -5.0_f64..5.0,
alpha in -3.0_f64..3.0,
) {
let v = TangentVec { dx: vx, dy: vy };
let av = TangentVec { dx: alpha * vx, dy: alpha * vy };
let ratio = cov.quad_form(av);
let expected = alpha * alpha * cov.quad_form(v);
prop_assert!((ratio - expected).abs() < 1e-8, "{} ≠ {}", ratio, expected);
}
#[test]
fn solve_forward_inverts_cholesky_lower(
cov in psd_cov2(),
vx in -5.0_f64..5.0,
vy in -5.0_f64..5.0,
) {
let v = TangentVec { dx: vx, dy: vy };
let l = cov.cholesky_lower(1e-12).expect("PSD should decompose");
let z = l.solve_forward(v);
let rx = l.l00 * z.dx;
let ry = l.l10 * z.dx + l.l11 * z.dy;
prop_assert!((rx - vx).abs() < 1e-8, "rx {} ≠ vx {}", rx, vx);
prop_assert!((ry - vy).abs() < 1e-8, "ry {} ≠ vy {}", ry, vy);
}
}
}