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
717use 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) ]
195215fn 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}
0 commit comments