Skip to main content

la_stack/
ldlt.rs

1#![forbid(unsafe_code)]
2
3//! LDLT factorization and solves.
4//!
5//! This module provides a stack-allocated LDLT factorization (`A = L D Lᵀ`)
6//! without pivoting. Successful factors require an exactly symmetric input and
7//! every computed diagonal pivot to be positive and above the caller's
8//! tolerance. Computed zero and tolerance-small positive pivots are diagnosed
9//! rather than returned in a usable factor. See `REFERENCES.md` \[4-6, 11-12\] for
10//! Cholesky/LDLT background and pivoted symmetric-indefinite alternatives.
11//!
12//! # Preconditions
13//! The input matrix must be **symmetric**.  This is a correctness contract, not a hint:
14//! the factorization algorithm reads only the lower triangle and implicitly assumes the
15//! upper triangle mirrors it exactly. Asymmetric inputs return [`LaError::Asymmetric`]
16//! with an allowed absolute difference of `0.0` before factorization starts. IEEE-754
17//! signed zeros compare equal and are accepted. Callers who know their matrices may
18//! not be symmetric at all should use [`crate::Lu`] instead.
19
20use core::hint::cold_path;
21
22use crate::matrix::SymmetricMatrix;
23use crate::scaled_product::{ScaledProduct, range_checked_product};
24use crate::vector::Vector;
25use crate::{ArithmeticOperation, FactorizationKind, LaError, Tolerance};
26
27/// LDLT factorization (`A = L D Lᵀ`) for exactly symmetric positive-definite matrices.
28///
29/// `Ldlt<0>` represents the empty factorization. Its determinant is the empty
30/// product `1.0`, and solving against [`Vector<0>`] returns [`Vector<0>`].
31///
32/// This factorization is **not** a general-purpose symmetric-indefinite LDLT (no pivoting).
33/// It assumes the input matrix is exactly symmetric and numerically positive
34/// definite under the caller's absolute pivot tolerance. An uncoupled computed
35/// zero or a tolerance-small positive pivot returns [`LaError::Singular`]; a
36/// computed zero with non-zero remaining coupling returns
37/// [`LaError::NotPositiveSemidefinite`]. Because pivots are computed in
38/// binary64, success is not an exact proof that the stored matrix is positive
39/// definite.
40///
41/// # Preconditions
42/// The source matrix passed to [`Matrix::ldlt`](crate::Matrix::ldlt) must be
43/// exactly symmetric (`A[i][j] == A[j][i]` for every mirrored pair). Asymmetric
44/// inputs return [`LaError::Asymmetric`] before factorization starts; see
45/// [`Matrix::ldlt`](crate::Matrix::ldlt) for details and alternatives.
46///
47/// # Storage
48/// The factors are stored in one inline row-major array:
49/// - `D` is stored on the diagonal.
50/// - The strict lower triangle stores the multipliers of `L`.
51/// - The diagonal of `L` is implicit ones.
52#[must_use]
53#[derive(Clone, Copy, Debug, PartialEq)]
54pub struct Ldlt<const D: usize> {
55    factors: LdltFactors<D>,
56}
57
58/// In-place LDLT factor storage whose diagonal entries are finite and usable.
59///
60/// Construction through [`Ldlt::factor_symmetric`] proves every stored entry is
61/// finite and every diagonal satisfies the factorization tolerance.
62#[derive(Clone, Copy, Debug, PartialEq)]
63struct LdltFactors<const D: usize> {
64    storage: [[f64; D]; D],
65}
66
67impl<const D: usize> LdltFactors<D> {
68    /// Store rows after the factorization loop has proven all factor invariants.
69    #[inline]
70    const fn from_proven_rows(storage: [[f64; D]; D]) -> Self {
71        Self { storage }
72    }
73
74    /// Borrow a factor row.
75    #[inline]
76    #[must_use]
77    const fn row(&self, index: usize) -> &[f64; D] {
78        &self.storage[index]
79    }
80
81    /// Return a diagonal entry of `D`.
82    #[inline]
83    #[must_use]
84    const fn diag(&self, index: usize) -> f64 {
85        self.storage[index][index]
86    }
87}
88
89impl<const D: usize> Ldlt<D> {
90    /// Factor a finite, symmetry-proven matrix for
91    /// [`Matrix::ldlt`](crate::Matrix::ldlt).
92    ///
93    /// Consuming [`SymmetricMatrix`] lets the factorization read only the lower
94    /// triangle without revalidating symmetry. A successful result contains
95    /// only finite factor storage with diagonals above `tol`.
96    ///
97    /// # Errors
98    /// Returns [`LaError::NotPositiveSemidefinite`] for a negative pivot or a
99    /// zero pivot with non-zero coupling, [`LaError::Singular`] for an uncoupled
100    /// zero pivot or a positive pivot at or below `tol`, and [`LaError::NonFinite`]
101    /// when a pivot, multiplier, or update is not finite.
102    #[inline]
103    pub(crate) fn factor_symmetric(a: SymmetricMatrix<D>, tol: Tolerance) -> Result<Self, LaError> {
104        let mut rows = a.into_matrix().into_rows();
105        let tolerance = tol.get();
106
107        {
108            let rows = &mut rows;
109
110            // LDLT via symmetric rank-1 updates, using only the lower triangle.
111            for j in 0..D {
112                let d = rows[j][j];
113                if !(d.is_finite() && d > tolerance) {
114                    cold_path();
115                    return Err(Self::pivot_failure(rows, j, d, tolerance));
116                }
117                if D <= 5 {
118                    // Tiny matrices benchmark better when column normalization stays
119                    // separate from the trailing update.
120                    #[expect(
121                        clippy::needless_range_loop,
122                        reason = "the row index identifies the lower-triangle entry and any reported non-finite coordinate"
123                    )]
124                    for i in (j + 1)..D {
125                        let l = rows[i][j] / d;
126                        if !l.is_finite() {
127                            cold_path();
128                            return Err(LaError::non_finite_computation_matrix(
129                                ArithmeticOperation::LdltFactorization,
130                                i,
131                                j,
132                            ));
133                        }
134                        rows[i][j] = l;
135                    }
136
137                    for i in (j + 1)..D {
138                        let l_i = rows[i][j];
139                        let l_i_d = l_i * d;
140
141                        #[expect(
142                            clippy::needless_range_loop,
143                            reason = "the triangular column index coordinates multiplier reads with in-place trailing-row writes"
144                        )]
145                        for k in (j + 1)..=i {
146                            let l_k = rows[k][j];
147                            let new_val = (-l_i_d).mul_add(l_k, rows[i][k]);
148                            rows[i][k] = new_val;
149                        }
150                    }
151                } else {
152                    // Larger fixed dimensions avoid an extra column walk by updating
153                    // each lower-triangular row prefix as soon as its multiplier is finite.
154                    for i in (j + 1)..D {
155                        let l_i = rows[i][j] / d;
156                        if !l_i.is_finite() {
157                            cold_path();
158                            return Err(LaError::non_finite_computation_matrix(
159                                ArithmeticOperation::LdltFactorization,
160                                i,
161                                j,
162                            ));
163                        }
164                        rows[i][j] = l_i;
165
166                        let l_i_d = l_i * d;
167
168                        #[expect(
169                            clippy::needless_range_loop,
170                            reason = "the triangular column index coordinates normalized-column reads with the fused in-place update"
171                        )]
172                        for k in (j + 1)..=i {
173                            let l_k = rows[k][j];
174                            let new_val = (-l_i_d).mul_add(l_k, rows[i][k]);
175                            rows[i][k] = new_val;
176                        }
177                    }
178                }
179            }
180        }
181
182        // Every computed lower-triangular entry is checked when it becomes a
183        // pivot or multiplier; the untouched upper triangle remains finite input.
184        Ok(Self {
185            factors: LdltFactors::from_proven_rows(rows),
186        })
187    }
188
189    /// Return the first non-finite factor cell in row-major order.
190    fn non_finite_factor_error(rows: &[[f64; D]; D]) -> Option<LaError> {
191        for (row, values) in rows.iter().enumerate() {
192            for (col, value) in values.iter().enumerate() {
193                if !value.is_finite() {
194                    return Some(LaError::non_finite_computation_matrix(
195                        ArithmeticOperation::LdltFactorization,
196                        row,
197                        col,
198                    ));
199                }
200            }
201        }
202        None
203    }
204
205    /// Classify a failed diagonal check outside the successful factorization path.
206    fn pivot_failure(
207        rows: &[[f64; D]; D],
208        pivot_col: usize,
209        pivot: f64,
210        tolerance: f64,
211    ) -> LaError {
212        if !pivot.is_finite() {
213            return LaError::non_finite_computation_matrix(
214                ArithmeticOperation::LdltFactorization,
215                pivot_col,
216                pivot_col,
217            );
218        }
219        if pivot < 0.0 {
220            return Self::non_finite_factor_error(rows)
221                .unwrap_or_else(|| LaError::not_positive_semidefinite_negative(pivot_col, pivot));
222        }
223        if pivot == 0.0 {
224            return Self::zero_pivot_failure(rows, pivot_col, tolerance);
225        }
226        Self::non_finite_factor_error(rows).unwrap_or_else(|| {
227            LaError::singular_numerical(pivot_col, FactorizationKind::Ldlt, pivot, tolerance)
228        })
229    }
230
231    /// Classify a zero pivot after checking factor storage and every coupling.
232    fn zero_pivot_failure(rows: &[[f64; D]; D], pivot_col: usize, tolerance: f64) -> LaError {
233        if let Some(error) = Self::non_finite_factor_error(rows) {
234            return error;
235        }
236        for (row, values) in rows.iter().enumerate().skip(pivot_col + 1) {
237            let coupling = values[pivot_col];
238            if coupling != 0.0 {
239                return LaError::not_positive_semidefinite_zero_coupling(pivot_col, row, coupling);
240            }
241        }
242        LaError::singular_numerical(pivot_col, FactorizationKind::Ldlt, 0.0, tolerance)
243    }
244
245    /// Determinant of the original matrix.
246    ///
247    /// For a successfully constructed factorization, this is the product of
248    /// the diagonal terms of `D`.
249    ///
250    /// # Examples
251    /// ```
252    /// use la_stack::prelude::*;
253    ///
254    /// # fn main() -> Result<(), LaError> {
255    /// // Symmetric SPD matrix.
256    /// let a = Matrix::<2>::try_from_rows([[4.0, 2.0], [2.0, 3.0]])?;
257    /// let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL)?;
258    ///
259    /// assert!((ldlt.det()? - 8.0).abs() <= 1e-12);
260    /// # Ok(())
261    /// # }
262    /// ```
263    ///
264    /// Diagonal pivots are multiplied directly while each non-zero running
265    /// product remains finite and normal. If direct accumulation detects range
266    /// loss, all pivots are recomputed with power-of-two scaling before a
267    /// premature overflow or underflow can affect the returned determinant.
268    /// The final product is rounded to `f64`; a non-zero magnitude below the
269    /// binary64 range may round to zero. No certified absolute error bound is
270    /// provided.
271    ///
272    /// # Errors
273    /// Returns [`LaError::NonFinite`] if the final scaled determinant cannot be
274    /// represented as a finite `f64`.
275    #[inline]
276    pub const fn det(&self) -> Result<f64, LaError> {
277        let mut det = 1.0;
278        let mut i = 0;
279
280        if D <= 4 {
281            // Tiny determinants compose better with factorization when range
282            // loss exits immediately instead of carrying an aggregate proof.
283            while i < D {
284                let step = range_checked_product(det, self.factors.diag(i));
285                if !step.range_preserved() {
286                    cold_path();
287                    return self.scaled_det();
288                }
289                det = step.product();
290                i += 1;
291            }
292            return Ok(det);
293        }
294
295        let mut range_preserved = true;
296        while i < D {
297            let factor = self.factors.diag(i);
298            let step = range_checked_product(det, factor);
299            det = step.product();
300            range_preserved &= step.range_preserved();
301            i += 1;
302        }
303        if range_preserved {
304            Ok(det)
305        } else {
306            cold_path();
307            self.scaled_det()
308        }
309    }
310
311    /// Recompute the determinant with normalized mantissa/exponent scaling.
312    #[cold]
313    const fn scaled_det(&self) -> Result<f64, LaError> {
314        let mut product = ScaledProduct::new(false);
315        let mut i = 0;
316        while i < D {
317            product.multiply(self.factors.diag(i));
318            i += 1;
319        }
320
321        if let Some(det) = product.finish() {
322            Ok(det)
323        } else {
324            Err(LaError::non_finite_computation_step(
325                ArithmeticOperation::Determinant,
326                D.saturating_sub(1),
327            ))
328        }
329    }
330
331    /// Solve `A x = b` using this LDLT factorization.
332    ///
333    /// [`Vector`] is finite by construction, so this method only checks computed
334    /// substitution overflows. It performs floating-point substitution and does
335    /// not provide a certified absolute rounding-error bound for the returned
336    /// solution.
337    ///
338    /// # Examples
339    /// ```
340    /// use la_stack::prelude::*;
341    ///
342    /// # fn main() -> Result<(), LaError> {
343    /// let a = Matrix::<2>::try_from_rows([[4.0, 2.0], [2.0, 3.0]])?;
344    /// let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL)?;
345    ///
346    /// let b = Vector::<2>::try_new([1.0, 2.0])?;
347    /// let x = ldlt.solve(b)?.into_array();
348    ///
349    /// assert!((x[0] - (-0.125)).abs() <= 1e-12);
350    /// assert!((x[1] - 0.75).abs() <= 1e-12);
351    /// # Ok(())
352    /// # }
353    /// ```
354    ///
355    /// # Errors
356    /// Returns [`LaError::NonFinite`] if a computed substitution intermediate
357    /// overflows to NaN or infinity.
358    #[inline]
359    pub const fn solve(&self, b: Vector<D>) -> Result<Vector<D>, LaError> {
360        let mut x = b.into_array();
361
362        // Forward substitution: L y = b (L has unit diagonal).
363        let mut i = 0;
364        while i < D {
365            let mut sum = x[i];
366            let row = self.factors.row(i);
367            let mut j = 0;
368            while j < i {
369                sum = (-row[j]).mul_add(x[j], sum);
370                j += 1;
371            }
372            if !sum.is_finite() {
373                cold_path();
374                return Err(LaError::non_finite_computation_step(
375                    ArithmeticOperation::LdltSolve,
376                    i,
377                ));
378            }
379            x[i] = sum;
380            i += 1;
381        }
382
383        // Diagonal solve: D z = y.
384        let mut i = 0;
385        while i < D {
386            let diag = self.factors.diag(i);
387
388            let quotient = x[i] / diag;
389            if !quotient.is_finite() {
390                cold_path();
391                return Err(LaError::non_finite_computation_step(
392                    ArithmeticOperation::LdltSolve,
393                    i,
394                ));
395            }
396            x[i] = quotient;
397            i += 1;
398        }
399
400        if D <= 4 {
401            // Tiny matrices benchmark better with the direct textbook dot
402            // product for each row of Lᵀ.
403            let mut ii = 0;
404            while ii < D {
405                let i = D - 1 - ii;
406                let mut sum = x[i];
407                let mut j = i + 1;
408                while j < D {
409                    sum = (-self.factors.row(j)[i]).mul_add(x[j], sum);
410                    j += 1;
411                }
412                if !sum.is_finite() {
413                    cold_path();
414                    return Err(LaError::non_finite_computation_step(
415                        ArithmeticOperation::LdltSolve,
416                        i,
417                    ));
418                }
419                x[i] = sum;
420                ii += 1;
421            }
422        } else {
423            // Larger fixed dimensions benchmark better by walking finalized
424            // rows downward and scattering contributions into the remaining
425            // contiguous lower-triangular row prefix.
426            let mut jj = D;
427            while jj > 0 {
428                jj -= 1;
429
430                let x_j = x[jj];
431                if !x_j.is_finite() {
432                    cold_path();
433                    return Err(LaError::non_finite_computation_step(
434                        ArithmeticOperation::LdltSolve,
435                        jj,
436                    ));
437                }
438
439                let row = self.factors.row(jj);
440                let mut i = 0;
441                while i < jj {
442                    x[i] = (-row[i]).mul_add(x_j, x[i]);
443                    i += 1;
444                }
445            }
446        }
447
448        Vector::from_computation(x, ArithmeticOperation::LdltSolve)
449    }
450}
451
452#[cfg(test)]
453mod tests {
454    use core::hint::black_box;
455
456    use approx::assert_abs_diff_eq;
457    use pastey::paste;
458
459    use super::*;
460    use crate::DEFAULT_SINGULAR_TOL;
461    use crate::matrix::Matrix;
462
463    const TWO_NEG_800: f64 = f64::from_bits(223_u64 << 52);
464    const TWO_NEG_38: f64 = f64::from_bits(985_u64 << 52);
465    const TWO_POS_43: f64 = f64::from_bits(1066_u64 << 52);
466    const TWO_POS_800: f64 = f64::from_bits(1823_u64 << 52);
467
468    macro_rules! gen_ldlt_identity_tests {
469        ($d:literal) => {
470            paste! {
471                #[test]
472                fn [<ldlt_det_and_solve_identity_ $d d>]() {
473                    let a = Matrix::<$d>::identity();
474                    let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
475
476                    assert_abs_diff_eq!(ldlt.det().unwrap(), 1.0, epsilon = 1e-12);
477
478                    let b_arr = {
479                        let mut arr = [0.0f64; $d];
480                        let values = [1.0f64, 2.0, 3.0, 4.0, 5.0];
481                        for (dst, src) in arr.iter_mut().zip(values.iter()) {
482                            *dst = *src;
483                        }
484                        arr
485                    };
486                    let b = Vector::<$d>::new(black_box(b_arr));
487                    let x = ldlt.solve(b).unwrap().into_array();
488
489                    for i in 0..$d {
490                        assert_abs_diff_eq!(x[i], b_arr[i], epsilon = 1e-12);
491                    }
492                }
493            }
494        };
495    }
496
497    gen_ldlt_identity_tests!(2);
498    gen_ldlt_identity_tests!(3);
499    gen_ldlt_identity_tests!(4);
500    gen_ldlt_identity_tests!(5);
501
502    macro_rules! gen_ldlt_diagonal_tests {
503        ($d:literal) => {
504            paste! {
505                #[test]
506                fn [<ldlt_det_and_solve_diagonal_spd_ $d d>]() {
507                    let diag = {
508                        let mut arr = [0.0f64; $d];
509                        let values = [1.0f64, 2.0, 3.0, 4.0, 5.0];
510                        for (dst, src) in arr.iter_mut().zip(values.iter()) {
511                            *dst = *src;
512                        }
513                        arr
514                    };
515
516                    let mut rows = [[0.0f64; $d]; $d];
517                    for i in 0..$d {
518                        rows[i][i] = diag[i];
519                    }
520
521                    let a = Matrix::<$d>::try_from_rows(black_box(rows)).unwrap();
522                    let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
523
524                    let expected_det = {
525                        let mut acc = 1.0;
526                        for i in 0..$d {
527                            acc *= diag[i];
528                        }
529                        acc
530                    };
531                    assert_abs_diff_eq!(ldlt.det().unwrap(), expected_det, epsilon = 1e-12);
532
533                    let b_arr = {
534                        let mut arr = [0.0f64; $d];
535                        let values = [5.0f64, 4.0, 3.0, 2.0, 1.0];
536                        for (dst, src) in arr.iter_mut().zip(values.iter()) {
537                            *dst = *src;
538                        }
539                        arr
540                    };
541
542                    let b = Vector::<$d>::new(black_box(b_arr));
543                    let x = ldlt.solve(b).unwrap().into_array();
544
545                    for i in 0..$d {
546                        assert_abs_diff_eq!(x[i], b_arr[i] / diag[i], epsilon = 1e-12);
547                    }
548                }
549            }
550        };
551    }
552
553    gen_ldlt_diagonal_tests!(2);
554    gen_ldlt_diagonal_tests!(3);
555    gen_ldlt_diagonal_tests!(4);
556    gen_ldlt_diagonal_tests!(5);
557
558    #[test]
559    fn solve_0x0_returns_empty_vector_and_unit_det() {
560        let a = Matrix::<0>::zero();
561        let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
562
563        assert_eq!(ldlt.det(), Ok(1.0));
564        assert!(
565            ldlt.solve(Vector::<0>::zero())
566                .unwrap()
567                .into_array()
568                .is_empty()
569        );
570    }
571
572    #[test]
573    fn solve_2x2_known_spd() {
574        let a = Matrix::<2>::try_from_rows(black_box([[4.0, 2.0], [2.0, 3.0]])).unwrap();
575        let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
576
577        let b = Vector::<2>::new(black_box([1.0, 2.0]));
578        let x = ldlt.solve(b).unwrap().into_array();
579
580        assert_abs_diff_eq!(x[0], -0.125, epsilon = 1e-12);
581        assert_abs_diff_eq!(x[1], 0.75, epsilon = 1e-12);
582        assert_abs_diff_eq!(ldlt.det().unwrap(), 8.0, epsilon = 1e-12);
583    }
584
585    #[test]
586    fn det_ordinary_factors_matches_direct_product_bits() {
587        let diagonal = [1.5, 2.0, 0.25, 8.0];
588        let mut rows = [[0.0; 4]; 4];
589        let mut expected = 1.0;
590        for (i, factor) in diagonal.into_iter().enumerate() {
591            rows[i][i] = factor;
592            expected *= factor;
593        }
594
595        let ldlt = Matrix::<4>::try_from_rows(rows)
596            .unwrap()
597            .ldlt(DEFAULT_SINGULAR_TOL)
598            .unwrap();
599        assert_eq!(ldlt.det().unwrap().to_bits(), expected.to_bits());
600    }
601
602    #[test]
603    fn solve_3x3_spd_tridiagonal_smoke() {
604        let a = Matrix::<3>::try_from_rows(black_box([
605            [2.0, -1.0, 0.0],
606            [-1.0, 2.0, -1.0],
607            [0.0, -1.0, 2.0],
608        ]))
609        .unwrap();
610        let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
611
612        // Choose x = 1 so b = A x is simple: [1, 0, 1].
613        let b = Vector::<3>::new(black_box([1.0, 0.0, 1.0]));
614        let x = ldlt.solve(b).unwrap().into_array();
615
616        for &x_i in &x {
617            assert_abs_diff_eq!(x_i, 1.0, epsilon = 1e-9);
618        }
619    }
620
621    #[test]
622    fn singular_detected_for_degenerate_psd() {
623        // Rank-1 Gram-like matrix.
624        let a = Matrix::<2>::try_from_rows(black_box([[1.0, 1.0], [1.0, 1.0]])).unwrap();
625        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
626        assert_eq!(
627            err,
628            LaError::singular_numerical(
629                1,
630                FactorizationKind::Ldlt,
631                0.0,
632                DEFAULT_SINGULAR_TOL.get()
633            )
634        );
635    }
636
637    #[test]
638    fn zero_pivot_with_nonzero_coupling_is_not_reported_as_singular() {
639        let a = Matrix::<2>::try_from_rows(black_box([[0.0, 1.0], [1.0, 0.0]])).unwrap();
640        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
641        assert_eq!(
642            err,
643            LaError::not_positive_semidefinite_zero_coupling(0, 1, 1.0)
644        );
645    }
646
647    #[test]
648    fn zero_pivot_reports_non_finite_coupling_before_domain_violation() {
649        let a = Matrix::<3>::try_from_rows(black_box([
650            [1.0, 1.0, f64::MAX],
651            [1.0, 1.0, -f64::MAX],
652            [f64::MAX, -f64::MAX, 1.0],
653        ]))
654        .unwrap();
655        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
656        assert_eq!(
657            err,
658            LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 2, 1,)
659        );
660    }
661
662    #[test]
663    fn small_positive_pivot_reports_numerical_singularity() {
664        let a = Matrix::<2>::try_from_rows(black_box([[1e-13, 0.0], [0.0, 1.0]])).unwrap();
665        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
666        assert_eq!(
667            err,
668            LaError::singular_numerical(
669                0,
670                FactorizationKind::Ldlt,
671                1e-13,
672                DEFAULT_SINGULAR_TOL.get()
673            )
674        );
675    }
676
677    #[test]
678    fn small_positive_pivot_does_not_mask_earlier_non_finite_update() {
679        let a = Matrix::<3>::try_from_rows(black_box([
680            [1.0, 1.0, f64::MAX],
681            [1.0, 1.0 + f64::EPSILON, -f64::MAX],
682            [f64::MAX, -f64::MAX, 1.0],
683        ]))
684        .unwrap();
685
686        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
687        assert_eq!(
688            err,
689            LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 2, 1)
690        );
691    }
692
693    #[test]
694    fn negative_initial_diagonal_reports_not_positive_semidefinite() {
695        let a = Matrix::<2>::try_from_rows(black_box([[-1.0, 0.0], [0.0, 1.0]])).unwrap();
696        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
697        assert_eq!(err, LaError::not_positive_semidefinite_negative(0, -1.0));
698    }
699
700    #[test]
701    fn negative_updated_diagonal_reports_not_positive_semidefinite() {
702        let a = Matrix::<2>::try_from_rows(black_box([[1.0, 2.0], [2.0, 1.0]])).unwrap();
703        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
704        assert_eq!(err, LaError::not_positive_semidefinite_negative(1, -3.0));
705    }
706
707    #[test]
708    fn negative_pivot_does_not_mask_earlier_non_finite_update() {
709        let a = Matrix::<3>::try_from_rows(black_box([
710            [1.0, 2.0, f64::MAX],
711            [2.0, 1.0, 0.0],
712            [f64::MAX, 0.0, 1.0],
713        ]))
714        .unwrap();
715
716        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
717        assert_eq!(
718            err,
719            LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 2, 1,)
720        );
721    }
722
723    #[test]
724    fn non_finite_l_multiplier_overflow() {
725        // d = 1e-11 > tol, but l = 1e300 / 1e-11 = 1e311 overflows f64.
726        let a = Matrix::<2>::try_from_rows([[1e-11, 1e300], [1e300, 1.0]]).unwrap();
727        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
728        assert_eq!(
729            err,
730            LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 1, 0)
731        );
732    }
733
734    #[test]
735    fn non_finite_l_multiplier_overflow_fused_branch_6d() {
736        // D > 5 uses the fused LDLT update path. Keep the same overflow shape
737        // as the 2D test while forcing that branch.
738        let mut rows = [[0.0; 6]; 6];
739        for (i, row) in rows.iter_mut().enumerate() {
740            row[i] = 1.0;
741        }
742        rows[0][0] = 1e-11;
743        rows[0][5] = 1e300;
744        rows[5][0] = 1e300;
745
746        let a = Matrix::<6>::try_from_rows(rows).unwrap();
747        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
748        assert_eq!(
749            err,
750            LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 5, 0)
751        );
752    }
753
754    #[test]
755    fn non_finite_trailing_submatrix_overflow() {
756        // L multiplier is finite (1e200), but the rank-1 update
757        // (-1e200 * 1.0) * 1e200 + 1.0 overflows.
758        let a = Matrix::<2>::try_from_rows([[1.0, 1e200], [1e200, 1.0]]).unwrap();
759        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
760        assert_eq!(
761            err,
762            LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 1, 1)
763        );
764    }
765
766    #[test]
767    fn non_finite_trailing_submatrix_overflow_fused_branch_6d() {
768        // D > 5 uses the fused LDLT update path. The overflowing trailing
769        // diagonal is detected when it later becomes a pivot.
770        let mut rows = [[0.0; 6]; 6];
771        for (i, row) in rows.iter_mut().enumerate() {
772            row[i] = 1.0;
773        }
774        rows[0][5] = 1e200;
775        rows[5][0] = 1e200;
776
777        let a = Matrix::<6>::try_from_rows(rows).unwrap();
778        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
779        assert_eq!(
780            err,
781            LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 5, 5)
782        );
783    }
784
785    #[test]
786    fn non_finite_solve_forward_substitution_overflow() {
787        // SPD matrix with large L multiplier: L[1,0] = 1e153.
788        // Forward substitution overflows: y[1] = 0 - 1e153 * 1e156 = -inf.
789        let a = Matrix::<3>::try_from_rows([
790            [1.0, 1e153, 0.0],
791            [1e153, 1e306 + 1.0, 0.0],
792            [0.0, 0.0, 1.0],
793        ])
794        .unwrap();
795        let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
796
797        let b = Vector::<3>::new([1e156, 0.0, 0.0]);
798        let err = ldlt.solve(b).unwrap_err();
799        assert_eq!(
800            err,
801            LaError::non_finite_computation_step(ArithmeticOperation::LdltSolve, 1)
802        );
803    }
804
805    #[test]
806    fn non_finite_solve_back_substitution_overflow() {
807        // SPD matrix: [[1,0,0],[0,1,2],[0,2,5]] has LDLT factors
808        // D=[1,1,1], L[2,1]=2.  Forward sub and diagonal solve produce
809        // z=[0,0,1e308].  Back-substitution: x[2]=1e308 then
810        // x[1] = 0 - 2*1e308 = -inf (overflows f64).
811        let a = Matrix::<3>::try_from_rows([[1.0, 0.0, 0.0], [0.0, 1.0, 2.0], [0.0, 2.0, 5.0]])
812            .unwrap();
813        let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
814
815        let b = Vector::<3>::new([0.0, 0.0, 1e308]);
816        let err = ldlt.solve(b).unwrap_err();
817        assert_eq!(
818            err,
819            LaError::non_finite_computation_step(ArithmeticOperation::LdltSolve, 1)
820        );
821    }
822
823    #[test]
824    fn non_finite_solve_back_substitution_overflow_scatter_branch_5d() {
825        // Exercises the D >= 5 row-prefix scatter branch with the same
826        // bottom-right 2x2 SPD block used by the D3 back-substitution test.
827        let a = Matrix::<5>::try_from_rows([
828            [1.0, 0.0, 0.0, 0.0, 0.0],
829            [0.0, 1.0, 0.0, 0.0, 0.0],
830            [0.0, 0.0, 1.0, 0.0, 0.0],
831            [0.0, 0.0, 0.0, 1.0, 2.0],
832            [0.0, 0.0, 0.0, 2.0, 5.0],
833        ])
834        .unwrap();
835        let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
836
837        let b = Vector::<5>::new([0.0, 0.0, 0.0, 0.0, 1e308]);
838        let err = ldlt.solve(b).unwrap_err();
839        assert_eq!(
840            err,
841            LaError::non_finite_computation_step(ArithmeticOperation::LdltSolve, 3)
842        );
843    }
844
845    #[test]
846    fn non_finite_solve_diagonal_solve_overflow() {
847        // Diagonal SPD matrix with a tiny diagonal entry just above the
848        // singularity tolerance.  Forward substitution passes through the
849        // large RHS unchanged, then the diagonal solve z[1] = y[1] / D[1]
850        // = 1e300 / 1e-11 = 1e311 overflows f64, exercising the
851        // `!v.is_finite()` branch of the diagonal solve.
852        let a = Matrix::<2>::try_from_rows([[1.0, 0.0], [0.0, 1.0e-11]]).unwrap();
853        let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
854
855        let b = Vector::<2>::new([0.0, 1.0e300]);
856        let err = ldlt.solve(b).unwrap_err();
857        assert_eq!(
858            err,
859            LaError::non_finite_computation_step(ArithmeticOperation::LdltSolve, 1)
860        );
861    }
862
863    #[test]
864    fn det_rejects_product_overflow() {
865        let a = Matrix::<5>::try_from_rows([
866            [1.0e100, 0.0, 0.0, 0.0, 0.0],
867            [0.0, 1.0e100, 0.0, 0.0, 0.0],
868            [0.0, 0.0, 1.0e100, 0.0, 0.0],
869            [0.0, 0.0, 0.0, 1.0e100, 0.0],
870            [0.0, 0.0, 0.0, 0.0, 1.0e100],
871        ])
872        .unwrap();
873        let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
874        assert_eq!(
875            ldlt.det(),
876            Err(LaError::non_finite_computation_step(
877                ArithmeticOperation::Determinant,
878                4
879            ))
880        );
881    }
882
883    #[test]
884    fn det_balances_extreme_diagonals_independently_of_storage_order() {
885        let zero_tolerance = Tolerance::try_new(0.0).unwrap();
886        for diagonal in [
887            [TWO_NEG_800, TWO_NEG_800, TWO_POS_800, TWO_POS_800],
888            [TWO_POS_800, TWO_POS_800, TWO_NEG_800, TWO_NEG_800],
889        ] {
890            let mut rows = [[0.0; 4]; 4];
891            for (i, value) in diagonal.into_iter().enumerate() {
892                rows[i][i] = value;
893            }
894
895            let ldlt = Matrix::<4>::try_from_rows(rows)
896                .unwrap()
897                .ldlt(zero_tolerance)
898                .unwrap();
899            assert_eq!(ldlt.det(), Ok(1.0));
900        }
901    }
902
903    #[test]
904    fn det_balances_extreme_diagonals_in_large_dimension() {
905        let zero_tolerance = Tolerance::try_new(0.0).unwrap();
906        for diagonal in [
907            [TWO_NEG_800, TWO_NEG_800, TWO_POS_800, TWO_POS_800, 1.0, 1.0],
908            [TWO_POS_800, TWO_POS_800, TWO_NEG_800, TWO_NEG_800, 1.0, 1.0],
909        ] {
910            let mut rows = [[0.0; 6]; 6];
911            for (i, value) in diagonal.into_iter().enumerate() {
912                rows[i][i] = value;
913            }
914
915            let ldlt = Matrix::<6>::try_from_rows(rows)
916                .unwrap()
917                .ldlt(zero_tolerance)
918                .unwrap();
919            assert_eq!(ldlt.det(), Ok(1.0));
920        }
921    }
922
923    #[test]
924    fn det_rounds_final_tiny_magnitude_to_zero() {
925        let zero_tolerance = Tolerance::try_new(0.0).unwrap();
926        let matrix = Matrix::<2>::try_from_rows([[TWO_NEG_800, 0.0], [0.0, TWO_NEG_800]]).unwrap();
927        let det = matrix.ldlt(zero_tolerance).unwrap().det().unwrap();
928
929        assert_eq!(det.to_bits(), 0.0f64.to_bits());
930    }
931
932    #[test]
933    fn ldlt_d1_classifies_positive_zero_and_negative_inputs() {
934        let positive = Matrix::<1>::try_from_rows([[2.0]])
935            .unwrap()
936            .ldlt(DEFAULT_SINGULAR_TOL)
937            .unwrap();
938        assert_eq!(positive.det(), Ok(2.0));
939        assert_abs_diff_eq!(
940            positive
941                .solve(Vector::<1>::new([6.0]))
942                .unwrap()
943                .into_array()[0],
944            3.0,
945            epsilon = 0.0
946        );
947
948        let zero = Matrix::<1>::try_from_rows([[0.0]])
949            .unwrap()
950            .ldlt(DEFAULT_SINGULAR_TOL);
951        assert_eq!(
952            zero,
953            Err(LaError::singular_numerical(
954                0,
955                FactorizationKind::Ldlt,
956                0.0,
957                DEFAULT_SINGULAR_TOL.get()
958            ))
959        );
960
961        let negative = Matrix::<1>::try_from_rows([[-1.0]])
962            .unwrap()
963            .ldlt(DEFAULT_SINGULAR_TOL);
964        assert_eq!(
965            negative,
966            Err(LaError::not_positive_semidefinite_negative(0, -1.0))
967        );
968    }
969
970    /// Construct an exactly representable tridiagonal SPD system from a unit
971    /// lower-bidiagonal `L` and positive integer diagonal `D`.
972    fn nontrivial_spd_system<const D: usize>() -> (Matrix<D>, Vector<D>, [f64; D], f64) {
973        let mut rows = [[0.0_f64; D]; D];
974        let mut expected_det = 1.0_f64;
975        let mut diagonal = 1.0_f64;
976        let mut k = 0;
977        while k < D {
978            rows[k][k] += diagonal;
979            expected_det *= diagonal;
980            if k + 1 < D {
981                let off_diagonal = 0.5 * diagonal;
982                rows[k][k + 1] += off_diagonal;
983                rows[k + 1][k] += off_diagonal;
984                rows[k + 1][k + 1] = 0.25_f64.mul_add(diagonal, rows[k + 1][k + 1]);
985            }
986            diagonal += 1.0;
987            k += 1;
988        }
989
990        let mut expected_x = [0.0_f64; D];
991        let mut value = 1.0_f64;
992        for entry in &mut expected_x {
993            *entry = value;
994            value += 1.0;
995        }
996        let rhs = core::array::from_fn(|row| {
997            rows[row]
998                .iter()
999                .zip(expected_x.iter())
1000                .fold(0.0_f64, |sum, (&coefficient, &x)| {
1001                    coefficient.mul_add(x, sum)
1002                })
1003        });
1004
1005        (
1006            Matrix::<D>::try_from_rows(rows).unwrap(),
1007            Vector::<D>::try_new(rhs).unwrap(),
1008            expected_x,
1009            expected_det,
1010        )
1011    }
1012
1013    macro_rules! gen_nontrivial_large_ldlt_tests {
1014        ($d:literal) => {
1015            paste! {
1016                #[test]
1017                fn [<ldlt_nontrivial_success_solve_and_det_agree_ $d d>]() {
1018                    let (matrix, rhs, expected_x, expected_det) =
1019                        nontrivial_spd_system::<$d>();
1020                    let ldlt = matrix.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
1021                    let solution = ldlt.solve(rhs).unwrap().into_array();
1022
1023                    for (actual, expected) in solution.into_iter().zip(expected_x) {
1024                        assert_abs_diff_eq!(actual, expected, epsilon = 1e-12);
1025                    }
1026                    assert_abs_diff_eq!(ldlt.det().unwrap(), expected_det, epsilon = 1e-12);
1027                    assert_abs_diff_eq!(matrix.det().unwrap(), expected_det, epsilon = 1e-10);
1028                }
1029            }
1030        };
1031    }
1032
1033    gen_nontrivial_large_ldlt_tests!(6);
1034    gen_nontrivial_large_ldlt_tests!(8);
1035
1036    #[test]
1037    fn asymmetric_input_returns_typed_error() {
1038        // a[0][1] = 2.0 but a[1][0] = -2.0 → clearly asymmetric.
1039        let a = Matrix::<3>::try_from_rows([[4.0, 2.0, 0.0], [-2.0, 5.0, 1.0], [0.0, 1.0, 3.0]])
1040            .unwrap();
1041        assert_eq!(
1042            a.ldlt(DEFAULT_SINGULAR_TOL),
1043            Err(LaError::asymmetric(0, 1, 3, 2.0, -2.0, 0.0))
1044        );
1045    }
1046
1047    #[test]
1048    fn approximately_symmetric_input_is_rejected_before_factoring_another_operator() {
1049        // The tolerance-based diagnostic accepts this exact power-of-two
1050        // counterexample because 4 <= 1e-12 * 2^43. Factoring only its lower
1051        // triangle would instead replace the upper zero with 4: the original
1052        // determinant is 32, while that projected matrix has determinant 16.
1053        let matrix = Matrix::<2>::try_from_rows([[TWO_POS_43, 0.0], [4.0, TWO_NEG_38]]).unwrap();
1054        let diagnostic_tolerance = Tolerance::try_new(1e-12).unwrap();
1055
1056        assert_eq!(matrix.det(), Ok(32.0));
1057        assert_eq!(matrix.is_symmetric(diagnostic_tolerance), Ok(true));
1058        assert_eq!(
1059            matrix.ldlt(DEFAULT_SINGULAR_TOL),
1060            Err(LaError::asymmetric(0, 1, 2, 0.0, 4.0, 0.0))
1061        );
1062    }
1063
1064    // -----------------------------------------------------------------------
1065    // Const-evaluability tests.
1066    //
1067    // These prove that `Ldlt::det` and `Ldlt::solve` are truly `const fn`
1068    // by forcing the compiler to evaluate them inside a `const` initializer.
1069    // `Ldlt::factor` is not (yet) `const fn` because the rank-1 update loop
1070    // uses array indexing patterns that still require non-const helpers on
1071    // some toolchains; we therefore construct `Ldlt<D>` directly.
1072    // -----------------------------------------------------------------------
1073
1074    macro_rules! gen_ldlt_const_eval_tests {
1075        ($d:literal) => {
1076            paste! {
1077                /// `Ldlt::det` must be fully const-evaluable. Setting
1078                /// `factors[0][0] = 2.0` and leaving the remaining identity
1079                /// diagonals at `1.0` gives `det = 2.0` for every `D ≥ 1`,
1080                /// exercising the multiply-accumulate loop at each dimension.
1081                #[test]
1082                fn [<ldlt_det_const_eval_ $d d>]() {
1083                    const DET: Result<f64, LaError> = {
1084                        let mut rows = [[0.0f64; $d]; $d];
1085                        let mut i = 0;
1086                        while i < $d {
1087                            rows[i][i] = 1.0;
1088                            i += 1;
1089                        }
1090                        rows[0][0] = 2.0;
1091                        let factors = LdltFactors::from_proven_rows(rows);
1092                        let ldlt = Ldlt::<$d> { factors };
1093                        ldlt.det()
1094                    };
1095                    assert_eq!(DET, Ok(2.0));
1096                }
1097
1098                /// `Ldlt::solve` must be fully const-evaluable. Identity
1099                /// factors with RHS `b = [1.0, 2.0, …, D]` round-trips `b`
1100                /// unchanged, exercising the full forward sub / diagonal solve
1101                /// / back sub pipeline inside a `const { … }` initializer.
1102                #[test]
1103                fn [<ldlt_solve_const_eval_ $d d>]() {
1104                    #[expect(
1105                        clippy::cast_precision_loss,
1106                        reason = "test indices are at most five and exactly representable as f64"
1107                    )]
1108                    const X: Result<Vector<$d>, LaError> = {
1109                        let factors = LdltFactors::from_proven_rows(
1110                            Matrix::<$d>::identity().into_rows()
1111                        );
1112                        let ldlt = Ldlt::<$d> { factors };
1113                        let mut b_arr = [0.0f64; $d];
1114                        let mut i = 0;
1115                        while i < $d {
1116                            b_arr[i] = i as f64 + 1.0;
1117                            i += 1;
1118                        }
1119                        let b = Vector::<$d>::new(b_arr);
1120                        ldlt.solve(b)
1121                    };
1122                    let x = X.unwrap().into_array();
1123                    #[expect(
1124                        clippy::cast_precision_loss,
1125                        reason = "test indices are at most five and exactly representable as f64"
1126                    )]
1127                    for i in 0..$d {
1128                        let expected = i as f64 + 1.0;
1129                        assert!((x[i] - expected).abs() <= 1e-12);
1130                    }
1131                }
1132            }
1133        };
1134    }
1135
1136    gen_ldlt_const_eval_tests!(2);
1137    gen_ldlt_const_eval_tests!(3);
1138    gen_ldlt_const_eval_tests!(4);
1139    gen_ldlt_const_eval_tests!(5);
1140}