Skip to content

Commit aba1dc7

Browse files
authored
Merge pull request #93 from acgetchell/docs/84-ldlt-symmetry-precondition
docs: strengthen LDLT symmetry precondition and add is_symmetric API (#84)
2 parents b60a734 + 1805779 commit aba1dc7

4 files changed

Lines changed: 396 additions & 11 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,17 @@ let det = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap().det();
108108
assert!((det - 1.0).abs() <= 1e-12);
109109
```
110110

111+
> ⚠️ **LDLT precondition:** The input matrix must be **symmetric**. Symmetry is
112+
> verified by a `debug_assert!` in debug builds only; release builds silently accept
113+
> asymmetric inputs and produce a meaningless factorization. Pre-validate with
114+
> [`Matrix::is_symmetric`](https://docs.rs/la-stack/latest/la_stack/struct.Matrix.html#method.is_symmetric)
115+
> (or locate the offending pair with
116+
> [`Matrix::first_asymmetry`](https://docs.rs/la-stack/latest/la_stack/struct.Matrix.html#method.first_asymmetry))
117+
> when you cannot statically guarantee symmetry, or fall back to `lu()` if your
118+
> matrices may not be symmetric at all. See
119+
> [`Matrix::ldlt`](https://docs.rs/la-stack/latest/la_stack/struct.Matrix.html#method.ldlt)
120+
> for details.
121+
111122
## ⚡ Compile-time determinants (D ≤ 4)
112123

113124
`det_direct()` is a `const fn` providing closed-form determinants for D=0–4,

src/ldlt.rs

Lines changed: 102 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@
33
//! This module provides a stack-allocated LDLT factorization (`A = L D Lᵀ`) intended for
44
//! symmetric positive definite (SPD) and positive semi-definite (PSD) matrices (e.g. Gram
55
//! matrices) without pivoting.
6+
//!
7+
//! # Preconditions
8+
//! The input matrix must be **symmetric**. This is a correctness contract, not a hint:
9+
//! the factorization algorithm reads only the lower triangle and implicitly assumes the
10+
//! upper triangle mirrors it. Symmetry is verified by a `debug_assert!` in debug builds
11+
//! only; in release builds an asymmetric input will silently produce a meaningless
12+
//! factorization. Callers who cannot statically guarantee symmetry should pre-validate
13+
//! with [`Matrix::is_symmetric`](crate::Matrix::is_symmetric) (or locate the offending
14+
//! pair with [`Matrix::first_asymmetry`](crate::Matrix::first_asymmetry)), or fall back
15+
//! to [`crate::Lu`] if their matrices are not guaranteed to be symmetric at all.
616
717
use core::hint::cold_path;
818

@@ -15,6 +25,16 @@ use crate::vector::Vector;
1525
/// This factorization is **not** a general-purpose symmetric-indefinite LDLT (no pivoting).
1626
/// It assumes the input matrix is symmetric and (numerically) SPD/PSD.
1727
///
28+
/// # Preconditions
29+
/// The source matrix passed to [`Matrix::ldlt`](crate::Matrix::ldlt) must be symmetric
30+
/// (`A[i][j] == A[j][i]` within rounding). Asymmetric inputs panic in debug builds via
31+
/// `debug_assert!` and are silently accepted in release builds — producing a
32+
/// mathematically meaningless factorization whose [`Self::det`] and [`Self::solve_vec`]
33+
/// results are wrong without any error being reported. Pre-validate with
34+
/// [`Matrix::is_symmetric`](crate::Matrix::is_symmetric) when the input cannot be
35+
/// statically guaranteed symmetric; see [`Matrix::ldlt`](crate::Matrix::ldlt) for further
36+
/// details and alternatives.
37+
///
1838
/// # Storage
1939
/// The factors are stored in a single [`Matrix`]:
2040
/// - `D` is stored on the diagonal.
@@ -193,17 +213,18 @@ impl<const D: usize> Ldlt<D> {
193213

194214
#[cfg(debug_assertions)]
195215
fn debug_assert_symmetric<const D: usize>(a: &Matrix<D>) {
196-
let scale = a.inf_norm().max(1.0);
197-
let eps = 1e-12 * scale;
198-
199-
for r in 0..D {
200-
for c in (r + 1)..D {
201-
let diff = (a.rows[r][c] - a.rows[c][r]).abs();
202-
debug_assert!(
203-
diff <= eps,
204-
"matrix must be symmetric (diff={diff}, eps={eps}) at ({r}, {c})"
205-
);
206-
}
216+
// Delegate to the public predicate so the runtime check and the documented
217+
// contract on `Matrix::ldlt` cannot drift apart. `first_asymmetry` is used
218+
// (rather than `is_symmetric`) so the panic message can name the offending
219+
// pair — which is invaluable for debugging.
220+
if let Some((r, c)) = a.first_asymmetry(1e-12) {
221+
let diff = (a.rows[r][c] - a.rows[c][r]).abs();
222+
let eps = 1e-12 * a.inf_norm().max(1.0);
223+
debug_assert!(
224+
false,
225+
"matrix must be symmetric (diff={diff}, eps={eps}) at ({r}, {c}); \
226+
pre-validate with Matrix::is_symmetric before calling ldlt"
227+
);
207228
}
208229
}
209230

@@ -433,4 +454,74 @@ mod tests {
433454
let err = ldlt.solve_vec(b).unwrap_err();
434455
assert_eq!(err, LaError::NonFinite { row: None, col: 1 });
435456
}
457+
458+
/// Verifies the symmetry precondition documented on [`Matrix::ldlt`] is
459+
/// enforced by `debug_assert_symmetric` in debug builds. The test is
460+
/// gated on `debug_assertions` so `cargo test --release` simply skips it
461+
/// (the assertion is compiled out in release builds — see the
462+
/// Preconditions section of `Matrix::ldlt`).
463+
#[cfg(debug_assertions)]
464+
#[test]
465+
#[should_panic(expected = "matrix must be symmetric")]
466+
fn debug_asymmetric_input_panics() {
467+
// a[0][1] = 2.0 but a[1][0] = -2.0 → clearly asymmetric.
468+
let a = Matrix::<3>::from_rows([[4.0, 2.0, 0.0], [-2.0, 5.0, 1.0], [0.0, 1.0, 3.0]]);
469+
let _ = a.ldlt(DEFAULT_SINGULAR_TOL);
470+
}
471+
472+
// -----------------------------------------------------------------------
473+
// Defensive-path coverage for `solve_vec`.
474+
//
475+
// `Ldlt::factor` guarantees that every stored diagonal is finite and
476+
// strictly greater than the recorded `tol`. `solve_vec` still re-checks
477+
// both invariants as a safety net (see the `!diag.is_finite()` and
478+
// `diag <= self.tol` guards in the diagonal solve). Those branches are
479+
// unreachable through the public API, so the only way to exercise them
480+
// is to construct `Ldlt` directly with corrupt factors. The tests below
481+
// document and verify that the safety nets return the documented error
482+
// variants.
483+
// -----------------------------------------------------------------------
484+
485+
macro_rules! gen_solve_vec_defensive_tests {
486+
($d:literal) => {
487+
paste! {
488+
/// `solve_vec` must surface `NonFinite` when a stored
489+
/// diagonal is NaN, even though `factor` cannot produce
490+
/// such a factorization.
491+
#[test]
492+
fn [<solve_vec_defensive_non_finite_diagonal_ $d d>]() {
493+
let mut factors = Matrix::<$d>::identity();
494+
factors.rows[$d - 1][$d - 1] = f64::NAN;
495+
let ldlt = Ldlt::<$d> {
496+
factors,
497+
tol: DEFAULT_SINGULAR_TOL,
498+
};
499+
let b = Vector::<$d>::new([1.0; $d]);
500+
let err = ldlt.solve_vec(b).unwrap_err();
501+
assert_eq!(err, LaError::NonFinite { row: None, col: $d - 1 });
502+
}
503+
504+
/// `solve_vec` must surface `Singular` when a stored
505+
/// diagonal is at or below the recorded tolerance, even
506+
/// though `factor` cannot produce such a factorization.
507+
#[test]
508+
fn [<solve_vec_defensive_sub_tolerance_diagonal_ $d d>]() {
509+
let mut factors = Matrix::<$d>::identity();
510+
factors.rows[$d - 1][$d - 1] = 0.0;
511+
let ldlt = Ldlt::<$d> {
512+
factors,
513+
tol: DEFAULT_SINGULAR_TOL,
514+
};
515+
let b = Vector::<$d>::new([1.0; $d]);
516+
let err = ldlt.solve_vec(b).unwrap_err();
517+
assert_eq!(err, LaError::Singular { pivot_col: $d - 1 });
518+
}
519+
}
520+
};
521+
}
522+
523+
gen_solve_vec_defensive_tests!(2);
524+
gen_solve_vec_defensive_tests!(3);
525+
gen_solve_vec_defensive_tests!(4);
526+
gen_solve_vec_defensive_tests!(5);
436527
}

src/lu.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,4 +501,50 @@ mod tests {
501501
let err = lu.solve_vec(b).unwrap_err();
502502
assert_eq!(err, LaError::NonFinite { row: None, col: 1 });
503503
}
504+
505+
// -----------------------------------------------------------------------
506+
// Defensive-path coverage for `solve_vec`.
507+
//
508+
// `Lu::factor` guarantees that every stored U diagonal satisfies
509+
// `|U[i,i]| > tol`. `solve_vec` still re-checks during back-substitution
510+
// as a safety net (see the `diag.abs() <= self.tol` guard). That branch
511+
// is unreachable through the public API, so the only way to exercise it
512+
// is to construct `Lu` directly with a corrupt U. The tests below
513+
// document and verify that the safety net returns `Singular`.
514+
// -----------------------------------------------------------------------
515+
516+
macro_rules! gen_solve_vec_defensive_singular_tests {
517+
($d:literal) => {
518+
paste! {
519+
/// `solve_vec` must surface `Singular` when a stored U
520+
/// diagonal is at or below the recorded tolerance, even
521+
/// though `factor` cannot produce such a factorization.
522+
#[test]
523+
fn [<solve_vec_defensive_sub_tolerance_diagonal_ $d d>]() {
524+
let mut factors = Matrix::<$d>::identity();
525+
factors.rows[$d - 1][$d - 1] = 0.0;
526+
527+
let mut piv = [0usize; $d];
528+
for (i, p) in piv.iter_mut().enumerate() {
529+
*p = i;
530+
}
531+
532+
let lu = Lu::<$d> {
533+
factors,
534+
piv,
535+
piv_sign: 1.0,
536+
tol: DEFAULT_PIVOT_TOL,
537+
};
538+
let b = Vector::<$d>::new([0.0; $d]);
539+
let err = lu.solve_vec(b).unwrap_err();
540+
assert_eq!(err, LaError::Singular { pivot_col: $d - 1 });
541+
}
542+
}
543+
};
544+
}
545+
546+
gen_solve_vec_defensive_singular_tests!(2);
547+
gen_solve_vec_defensive_singular_tests!(3);
548+
gen_solve_vec_defensive_singular_tests!(4);
549+
gen_solve_vec_defensive_singular_tests!(5);
504550
}

0 commit comments

Comments
 (0)