Skip to content

Commit e26c283

Browse files
committed
feat!(matrix): enforce fallible matrix invariants
- Add typed errors for unsupported runtime dimensions, out-of-bounds indices, invalid tolerances, and asymmetric matrices. - Add checked matrix accessors and stack-matrix runtime dispatch up to D=7. - Validate symmetry, tolerances, and non-finite entries before symmetry checks and factorization return typed errors. - Update LDLT documentation and README guidance for runtime asymmetry rejection. BREAKING CHANGE: Matrix::is_symmetric now returns Result<bool, LaError>, and Matrix::first_asymmetry now returns Result<Option<(usize, usize)>, LaError>. BREAKING CHANGE: LaError no longer implements Eq because InvalidTolerance stores the original f64 tolerance value. BREAKING CHANGE: Matrix::ldlt rejects asymmetric inputs with LaError::Asymmetric, and LU/LDLT reject negative, NaN, or infinite tolerances with LaError::InvalidTolerance.
1 parent bfb0393 commit e26c283

8 files changed

Lines changed: 804 additions & 147 deletions

File tree

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,5 +65,8 @@ unsafe_code = "forbid"
6565
missing_docs = "warn"
6666
dead_code = "warn"
6767

68+
[lints.rustdoc]
69+
broken_intra_doc_links = "deny"
70+
6871
[lints.clippy]
6972
pedantic = { level = "warn", priority = -1 }

README.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
[![Audit dependencies](https://github.com/acgetchell/la-stack/actions/workflows/audit.yml/badge.svg)](https://github.com/acgetchell/la-stack/actions/workflows/audit.yml)
1212
[![Codacy Security Scan](https://github.com/acgetchell/la-stack/actions/workflows/codacy.yml/badge.svg)](https://github.com/acgetchell/la-stack/actions/workflows/codacy.yml)
1313

14+
![la-stack](docs/assets/la-stack.jpg)
15+
1416
Fast, stack-allocated linear algebra for fixed dimensions in Rust.
1517

1618
This crate grew from the need to support [`delaunay`](https://crates.io/crates/delaunay) with fast, stack-allocated linear algebra primitives and algorithms
@@ -117,14 +119,13 @@ let det = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap().det();
117119
assert!((det - 1.0).abs() <= 1e-12);
118120
```
119121

120-
> ⚠️ **LDLT precondition:** The input matrix must be **symmetric**. Symmetry is
121-
> verified by a `debug_assert!` in debug builds only; release builds silently accept
122-
> asymmetric inputs and produce a meaningless factorization. Pre-validate with
122+
> ⚠️ **LDLT invariant:** The input matrix must be **symmetric**. Asymmetric
123+
> inputs return a typed `LaError::Asymmetric` before factorization starts.
124+
> You can pre-check with
123125
> [`Matrix::is_symmetric`](https://docs.rs/la-stack/latest/la_stack/struct.Matrix.html#method.is_symmetric)
124126
> (or locate the offending pair with
125-
> [`Matrix::first_asymmetry`](https://docs.rs/la-stack/latest/la_stack/struct.Matrix.html#method.first_asymmetry))
126-
> when you cannot statically guarantee symmetry, or fall back to `lu()` if your
127-
> matrices may not be symmetric at all. See
127+
> [`Matrix::first_asymmetry`](https://docs.rs/la-stack/latest/la_stack/struct.Matrix.html#method.first_asymmetry)),
128+
> or fall back to `lu()` if your matrices may not be symmetric at all. See
128129
> [`Matrix::ldlt`](https://docs.rs/la-stack/latest/la_stack/struct.Matrix.html#method.ldlt)
129130
> for details.
130131

docs/assets/la-stack.jpg

49.2 KB
Loading

src/ldlt.rs

Lines changed: 67 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,33 +7,29 @@
77
//! # Preconditions
88
//! The input matrix must be **symmetric**. This is a correctness contract, not a hint:
99
//! 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.
10+
//! upper triangle mirrors it. Asymmetric inputs return [`LaError::Asymmetric`]
11+
//! before factorization starts. Callers who know their matrices may not be
12+
//! symmetric at all should use [`crate::Lu`] instead.
1613
1714
use core::hint::cold_path;
1815

1916
use crate::LaError;
2017
use crate::matrix::Matrix;
2118
use crate::vector::Vector;
2219

20+
/// Relative tolerance used by LDLT's mandatory symmetry validation.
21+
const LDLT_SYMMETRY_REL_TOL: f64 = 1e-12;
22+
2323
/// LDLT factorization (`A = L D Lᵀ`) for symmetric positive (semi)definite matrices.
2424
///
2525
/// This factorization is **not** a general-purpose symmetric-indefinite LDLT (no pivoting).
2626
/// It assumes the input matrix is symmetric and (numerically) SPD/PSD.
2727
///
2828
/// # 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.
29+
/// The source matrix passed to [`Matrix::ldlt`](crate::Matrix::ldlt) must be
30+
/// symmetric (`A[i][j] == A[j][i]` within rounding). Asymmetric inputs return
31+
/// [`LaError::Asymmetric`] before factorization starts; see
32+
/// [`Matrix::ldlt`](crate::Matrix::ldlt) for details and alternatives.
3733
///
3834
/// # Storage
3935
/// The factors are stored in a single [`Matrix`]:
@@ -48,12 +44,15 @@ pub struct Ldlt<const D: usize> {
4844
}
4945

5046
impl<const D: usize> Ldlt<D> {
47+
/// Factor a symmetric square matrix into in-place LDLT storage for [`Matrix::ldlt`].
48+
///
49+
/// This is the single validation boundary for LDLT construction: it rejects
50+
/// invalid tolerances, asymmetric inputs, non-finite values, and degenerate
51+
/// diagonals before callers can observe an [`Ldlt`] value.
5152
#[inline]
5253
pub(crate) fn factor(a: Matrix<D>, tol: f64) -> Result<Self, LaError> {
53-
debug_assert!(tol >= 0.0, "tol must be non-negative");
54-
55-
#[cfg(debug_assertions)]
56-
debug_assert_symmetric(&a);
54+
let tol = LaError::validate_tolerance(tol)?;
55+
reject_asymmetric(&a)?;
5756

5857
let mut f = a;
5958

@@ -226,20 +225,17 @@ impl<const D: usize> Ldlt<D> {
226225
}
227226
}
228227

229-
#[cfg(debug_assertions)]
230-
fn debug_assert_symmetric<const D: usize>(a: &Matrix<D>) {
231-
// Delegate to the public predicate so the runtime check and the documented
232-
// contract on `Matrix::ldlt` cannot drift apart. `first_asymmetry` is used
233-
// (rather than `is_symmetric`) so the panic message can name the offending
234-
// pair — which is invaluable for debugging.
235-
if let Some((r, c)) = a.first_asymmetry(1e-12) {
236-
let diff = (a.rows[r][c] - a.rows[c][r]).abs();
237-
let eps = 1e-12 * a.inf_norm().max(1.0);
238-
debug_assert!(
239-
false,
240-
"matrix must be symmetric (diff={diff}, eps={eps}) at ({r}, {c}); \
241-
pre-validate with Matrix::is_symmetric before calling ldlt"
242-
);
228+
/// Reject asymmetric matrices before the no-pivot LDLT algorithm reads only the lower triangle.
229+
///
230+
/// This preserves the public [`Matrix::ldlt`] contract: an input that would
231+
/// otherwise produce a factorization for a different implicit matrix returns
232+
/// [`LaError::Asymmetric`] instead.
233+
fn reject_asymmetric<const D: usize>(a: &Matrix<D>) -> Result<(), LaError> {
234+
if let Some((row, col)) = a.first_asymmetry(LDLT_SYMMETRY_REL_TOL)? {
235+
cold_path();
236+
Err(LaError::asymmetric(row, col, D))
237+
} else {
238+
Ok(())
243239
}
244240
}
245241

@@ -396,6 +392,19 @@ mod tests {
396392
);
397393
}
398394

395+
#[test]
396+
fn nonfinite_offdiagonal_detected_before_asymmetry() {
397+
let a = Matrix::<2>::from_rows([[1.0, f64::NAN], [0.0, 1.0]]);
398+
let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
399+
assert_eq!(
400+
err,
401+
LaError::NonFinite {
402+
row: Some(0),
403+
col: 1,
404+
}
405+
);
406+
}
407+
399408
#[test]
400409
fn nonfinite_l_multiplier_overflow() {
401410
// d = 1e-11 > tol, but l = 1e300 / 1e-11 = 1e311 overflows f64.
@@ -470,18 +479,35 @@ mod tests {
470479
assert_eq!(err, LaError::NonFinite { row: None, col: 1 });
471480
}
472481

473-
/// Verifies the symmetry precondition documented on [`Matrix::ldlt`] is
474-
/// enforced by `debug_assert_symmetric` in debug builds. The test is
475-
/// gated on `debug_assertions` so `cargo test --release` simply skips it
476-
/// (the assertion is compiled out in release builds — see the
477-
/// Preconditions section of `Matrix::ldlt`).
478-
#[cfg(debug_assertions)]
479482
#[test]
480-
#[should_panic(expected = "matrix must be symmetric")]
481-
fn debug_asymmetric_input_panics() {
483+
fn asymmetric_input_returns_typed_error() {
482484
// a[0][1] = 2.0 but a[1][0] = -2.0 → clearly asymmetric.
483485
let a = Matrix::<3>::from_rows([[4.0, 2.0, 0.0], [-2.0, 5.0, 1.0], [0.0, 1.0, 3.0]]);
484-
let _ = a.ldlt(DEFAULT_SINGULAR_TOL);
486+
assert_eq!(
487+
a.ldlt(DEFAULT_SINGULAR_TOL),
488+
Err(LaError::Asymmetric {
489+
row: 0,
490+
col: 1,
491+
dim: 3,
492+
})
493+
);
494+
}
495+
496+
#[test]
497+
fn invalid_tolerance_rejected() {
498+
let a = Matrix::<2>::identity();
499+
assert_eq!(a.ldlt(-1.0), Err(LaError::InvalidTolerance { value: -1.0 }));
500+
501+
assert!(matches!(
502+
a.ldlt(f64::NAN),
503+
Err(LaError::InvalidTolerance { value }) if value.is_nan()
504+
));
505+
assert_eq!(
506+
a.ldlt(f64::INFINITY),
507+
Err(LaError::InvalidTolerance {
508+
value: f64::INFINITY,
509+
})
510+
);
485511
}
486512

487513
// -----------------------------------------------------------------------

0 commit comments

Comments
 (0)