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::{RangeCheckedProduct, 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        while i < D {
280            let factor = self.factors.diag(i);
281            match range_checked_product(det, factor) {
282                RangeCheckedProduct::Safe(next) => det = next,
283                RangeCheckedProduct::NeedsScaling => {
284                    cold_path();
285                    return self.scaled_det();
286                }
287            }
288            i += 1;
289        }
290        Ok(det)
291    }
292
293    /// Recompute the determinant with normalized mantissa/exponent scaling.
294    #[cold]
295    const fn scaled_det(&self) -> Result<f64, LaError> {
296        let mut product = ScaledProduct::new(false);
297        let mut i = 0;
298        while i < D {
299            product.multiply(self.factors.diag(i));
300            i += 1;
301        }
302
303        if let Some(det) = product.finish() {
304            Ok(det)
305        } else {
306            Err(LaError::non_finite_computation_step(
307                ArithmeticOperation::Determinant,
308                D.saturating_sub(1),
309            ))
310        }
311    }
312
313    /// Solve `A x = b` using this LDLT factorization.
314    ///
315    /// [`Vector`] is finite by construction, so this method only checks computed
316    /// substitution overflows. It performs floating-point substitution and does
317    /// not provide a certified absolute rounding-error bound for the returned
318    /// solution.
319    ///
320    /// # Examples
321    /// ```
322    /// use la_stack::prelude::*;
323    ///
324    /// # fn main() -> Result<(), LaError> {
325    /// let a = Matrix::<2>::try_from_rows([[4.0, 2.0], [2.0, 3.0]])?;
326    /// let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL)?;
327    ///
328    /// let b = Vector::<2>::try_new([1.0, 2.0])?;
329    /// let x = ldlt.solve(b)?.into_array();
330    ///
331    /// assert!((x[0] - (-0.125)).abs() <= 1e-12);
332    /// assert!((x[1] - 0.75).abs() <= 1e-12);
333    /// # Ok(())
334    /// # }
335    /// ```
336    ///
337    /// # Errors
338    /// Returns [`LaError::NonFinite`] if a computed substitution intermediate
339    /// overflows to NaN or infinity.
340    #[inline]
341    pub const fn solve(&self, b: Vector<D>) -> Result<Vector<D>, LaError> {
342        let mut x = b.into_array();
343
344        // Forward substitution: L y = b (L has unit diagonal).
345        let mut i = 0;
346        while i < D {
347            let mut sum = x[i];
348            let row = self.factors.row(i);
349            let mut j = 0;
350            while j < i {
351                sum = (-row[j]).mul_add(x[j], sum);
352                j += 1;
353            }
354            if !sum.is_finite() {
355                cold_path();
356                return Err(LaError::non_finite_computation_step(
357                    ArithmeticOperation::LdltSolve,
358                    i,
359                ));
360            }
361            x[i] = sum;
362            i += 1;
363        }
364
365        // Diagonal solve: D z = y.
366        let mut i = 0;
367        while i < D {
368            let diag = self.factors.diag(i);
369
370            let quotient = x[i] / diag;
371            if !quotient.is_finite() {
372                cold_path();
373                return Err(LaError::non_finite_computation_step(
374                    ArithmeticOperation::LdltSolve,
375                    i,
376                ));
377            }
378            x[i] = quotient;
379            i += 1;
380        }
381
382        if D <= 4 {
383            // Tiny matrices benchmark better with the direct textbook dot
384            // product for each row of Lᵀ.
385            let mut ii = 0;
386            while ii < D {
387                let i = D - 1 - ii;
388                let mut sum = x[i];
389                let mut j = i + 1;
390                while j < D {
391                    sum = (-self.factors.row(j)[i]).mul_add(x[j], sum);
392                    j += 1;
393                }
394                if !sum.is_finite() {
395                    cold_path();
396                    return Err(LaError::non_finite_computation_step(
397                        ArithmeticOperation::LdltSolve,
398                        i,
399                    ));
400                }
401                x[i] = sum;
402                ii += 1;
403            }
404        } else {
405            // Larger fixed dimensions benchmark better by walking finalized
406            // rows downward and scattering contributions into the remaining
407            // contiguous lower-triangular row prefix.
408            let mut jj = D;
409            while jj > 0 {
410                jj -= 1;
411
412                let x_j = x[jj];
413                if !x_j.is_finite() {
414                    cold_path();
415                    return Err(LaError::non_finite_computation_step(
416                        ArithmeticOperation::LdltSolve,
417                        jj,
418                    ));
419                }
420
421                let row = self.factors.row(jj);
422                let mut i = 0;
423                while i < jj {
424                    x[i] = (-row[i]).mul_add(x_j, x[i]);
425                    i += 1;
426                }
427            }
428        }
429
430        Vector::from_computation(x, ArithmeticOperation::LdltSolve)
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use core::hint::black_box;
437
438    use approx::assert_abs_diff_eq;
439    use pastey::paste;
440
441    use super::*;
442    use crate::DEFAULT_SINGULAR_TOL;
443    use crate::matrix::Matrix;
444
445    const TWO_NEG_800: f64 = f64::from_bits(223_u64 << 52);
446    const TWO_NEG_38: f64 = f64::from_bits(985_u64 << 52);
447    const TWO_POS_43: f64 = f64::from_bits(1066_u64 << 52);
448    const TWO_POS_800: f64 = f64::from_bits(1823_u64 << 52);
449
450    macro_rules! gen_ldlt_identity_tests {
451        ($d:literal) => {
452            paste! {
453                #[test]
454                fn [<ldlt_det_and_solve_identity_ $d d>]() {
455                    let a = Matrix::<$d>::identity();
456                    let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
457
458                    assert_abs_diff_eq!(ldlt.det().unwrap(), 1.0, epsilon = 1e-12);
459
460                    let b_arr = {
461                        let mut arr = [0.0f64; $d];
462                        let values = [1.0f64, 2.0, 3.0, 4.0, 5.0];
463                        for (dst, src) in arr.iter_mut().zip(values.iter()) {
464                            *dst = *src;
465                        }
466                        arr
467                    };
468                    let b = Vector::<$d>::new(black_box(b_arr));
469                    let x = ldlt.solve(b).unwrap().into_array();
470
471                    for i in 0..$d {
472                        assert_abs_diff_eq!(x[i], b_arr[i], epsilon = 1e-12);
473                    }
474                }
475            }
476        };
477    }
478
479    gen_ldlt_identity_tests!(2);
480    gen_ldlt_identity_tests!(3);
481    gen_ldlt_identity_tests!(4);
482    gen_ldlt_identity_tests!(5);
483
484    macro_rules! gen_ldlt_diagonal_tests {
485        ($d:literal) => {
486            paste! {
487                #[test]
488                fn [<ldlt_det_and_solve_diagonal_spd_ $d d>]() {
489                    let diag = {
490                        let mut arr = [0.0f64; $d];
491                        let values = [1.0f64, 2.0, 3.0, 4.0, 5.0];
492                        for (dst, src) in arr.iter_mut().zip(values.iter()) {
493                            *dst = *src;
494                        }
495                        arr
496                    };
497
498                    let mut rows = [[0.0f64; $d]; $d];
499                    for i in 0..$d {
500                        rows[i][i] = diag[i];
501                    }
502
503                    let a = Matrix::<$d>::try_from_rows(black_box(rows)).unwrap();
504                    let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
505
506                    let expected_det = {
507                        let mut acc = 1.0;
508                        for i in 0..$d {
509                            acc *= diag[i];
510                        }
511                        acc
512                    };
513                    assert_abs_diff_eq!(ldlt.det().unwrap(), expected_det, epsilon = 1e-12);
514
515                    let b_arr = {
516                        let mut arr = [0.0f64; $d];
517                        let values = [5.0f64, 4.0, 3.0, 2.0, 1.0];
518                        for (dst, src) in arr.iter_mut().zip(values.iter()) {
519                            *dst = *src;
520                        }
521                        arr
522                    };
523
524                    let b = Vector::<$d>::new(black_box(b_arr));
525                    let x = ldlt.solve(b).unwrap().into_array();
526
527                    for i in 0..$d {
528                        assert_abs_diff_eq!(x[i], b_arr[i] / diag[i], epsilon = 1e-12);
529                    }
530                }
531            }
532        };
533    }
534
535    gen_ldlt_diagonal_tests!(2);
536    gen_ldlt_diagonal_tests!(3);
537    gen_ldlt_diagonal_tests!(4);
538    gen_ldlt_diagonal_tests!(5);
539
540    #[test]
541    fn solve_0x0_returns_empty_vector_and_unit_det() {
542        let a = Matrix::<0>::zero();
543        let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
544
545        assert_eq!(ldlt.det(), Ok(1.0));
546        assert!(
547            ldlt.solve(Vector::<0>::zero())
548                .unwrap()
549                .into_array()
550                .is_empty()
551        );
552    }
553
554    #[test]
555    fn solve_2x2_known_spd() {
556        let a = Matrix::<2>::try_from_rows(black_box([[4.0, 2.0], [2.0, 3.0]])).unwrap();
557        let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
558
559        let b = Vector::<2>::new(black_box([1.0, 2.0]));
560        let x = ldlt.solve(b).unwrap().into_array();
561
562        assert_abs_diff_eq!(x[0], -0.125, epsilon = 1e-12);
563        assert_abs_diff_eq!(x[1], 0.75, epsilon = 1e-12);
564        assert_abs_diff_eq!(ldlt.det().unwrap(), 8.0, epsilon = 1e-12);
565    }
566
567    #[test]
568    fn det_ordinary_factors_matches_direct_product_bits() {
569        let diagonal = [1.5, 2.0, 0.25, 8.0];
570        let mut rows = [[0.0; 4]; 4];
571        let mut expected = 1.0;
572        for (i, factor) in diagonal.into_iter().enumerate() {
573            rows[i][i] = factor;
574            expected *= factor;
575        }
576
577        let ldlt = Matrix::<4>::try_from_rows(rows)
578            .unwrap()
579            .ldlt(DEFAULT_SINGULAR_TOL)
580            .unwrap();
581        assert_eq!(ldlt.det().unwrap().to_bits(), expected.to_bits());
582    }
583
584    #[test]
585    fn solve_3x3_spd_tridiagonal_smoke() {
586        let a = Matrix::<3>::try_from_rows(black_box([
587            [2.0, -1.0, 0.0],
588            [-1.0, 2.0, -1.0],
589            [0.0, -1.0, 2.0],
590        ]))
591        .unwrap();
592        let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
593
594        // Choose x = 1 so b = A x is simple: [1, 0, 1].
595        let b = Vector::<3>::new(black_box([1.0, 0.0, 1.0]));
596        let x = ldlt.solve(b).unwrap().into_array();
597
598        for &x_i in &x {
599            assert_abs_diff_eq!(x_i, 1.0, epsilon = 1e-9);
600        }
601    }
602
603    #[test]
604    fn singular_detected_for_degenerate_psd() {
605        // Rank-1 Gram-like matrix.
606        let a = Matrix::<2>::try_from_rows(black_box([[1.0, 1.0], [1.0, 1.0]])).unwrap();
607        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
608        assert_eq!(
609            err,
610            LaError::singular_numerical(
611                1,
612                FactorizationKind::Ldlt,
613                0.0,
614                DEFAULT_SINGULAR_TOL.get()
615            )
616        );
617    }
618
619    #[test]
620    fn zero_pivot_with_nonzero_coupling_is_not_reported_as_singular() {
621        let a = Matrix::<2>::try_from_rows(black_box([[0.0, 1.0], [1.0, 0.0]])).unwrap();
622        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
623        assert_eq!(
624            err,
625            LaError::not_positive_semidefinite_zero_coupling(0, 1, 1.0)
626        );
627    }
628
629    #[test]
630    fn zero_pivot_reports_non_finite_coupling_before_domain_violation() {
631        let a = Matrix::<3>::try_from_rows(black_box([
632            [1.0, 1.0, f64::MAX],
633            [1.0, 1.0, -f64::MAX],
634            [f64::MAX, -f64::MAX, 1.0],
635        ]))
636        .unwrap();
637        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
638        assert_eq!(
639            err,
640            LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 2, 1,)
641        );
642    }
643
644    #[test]
645    fn small_positive_pivot_reports_numerical_singularity() {
646        let a = Matrix::<2>::try_from_rows(black_box([[1e-13, 0.0], [0.0, 1.0]])).unwrap();
647        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
648        assert_eq!(
649            err,
650            LaError::singular_numerical(
651                0,
652                FactorizationKind::Ldlt,
653                1e-13,
654                DEFAULT_SINGULAR_TOL.get()
655            )
656        );
657    }
658
659    #[test]
660    fn small_positive_pivot_does_not_mask_earlier_non_finite_update() {
661        let a = Matrix::<3>::try_from_rows(black_box([
662            [1.0, 1.0, f64::MAX],
663            [1.0, 1.0 + f64::EPSILON, -f64::MAX],
664            [f64::MAX, -f64::MAX, 1.0],
665        ]))
666        .unwrap();
667
668        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
669        assert_eq!(
670            err,
671            LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 2, 1)
672        );
673    }
674
675    #[test]
676    fn negative_initial_diagonal_reports_not_positive_semidefinite() {
677        let a = Matrix::<2>::try_from_rows(black_box([[-1.0, 0.0], [0.0, 1.0]])).unwrap();
678        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
679        assert_eq!(err, LaError::not_positive_semidefinite_negative(0, -1.0));
680    }
681
682    #[test]
683    fn negative_updated_diagonal_reports_not_positive_semidefinite() {
684        let a = Matrix::<2>::try_from_rows(black_box([[1.0, 2.0], [2.0, 1.0]])).unwrap();
685        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
686        assert_eq!(err, LaError::not_positive_semidefinite_negative(1, -3.0));
687    }
688
689    #[test]
690    fn negative_pivot_does_not_mask_earlier_non_finite_update() {
691        let a = Matrix::<3>::try_from_rows(black_box([
692            [1.0, 2.0, f64::MAX],
693            [2.0, 1.0, 0.0],
694            [f64::MAX, 0.0, 1.0],
695        ]))
696        .unwrap();
697
698        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
699        assert_eq!(
700            err,
701            LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 2, 1,)
702        );
703    }
704
705    #[test]
706    fn non_finite_l_multiplier_overflow() {
707        // d = 1e-11 > tol, but l = 1e300 / 1e-11 = 1e311 overflows f64.
708        let a = Matrix::<2>::try_from_rows([[1e-11, 1e300], [1e300, 1.0]]).unwrap();
709        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
710        assert_eq!(
711            err,
712            LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 1, 0)
713        );
714    }
715
716    #[test]
717    fn non_finite_l_multiplier_overflow_fused_branch_6d() {
718        // D > 5 uses the fused LDLT update path. Keep the same overflow shape
719        // as the 2D test while forcing that branch.
720        let mut rows = [[0.0; 6]; 6];
721        for (i, row) in rows.iter_mut().enumerate() {
722            row[i] = 1.0;
723        }
724        rows[0][0] = 1e-11;
725        rows[0][5] = 1e300;
726        rows[5][0] = 1e300;
727
728        let a = Matrix::<6>::try_from_rows(rows).unwrap();
729        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
730        assert_eq!(
731            err,
732            LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 5, 0)
733        );
734    }
735
736    #[test]
737    fn non_finite_trailing_submatrix_overflow() {
738        // L multiplier is finite (1e200), but the rank-1 update
739        // (-1e200 * 1.0) * 1e200 + 1.0 overflows.
740        let a = Matrix::<2>::try_from_rows([[1.0, 1e200], [1e200, 1.0]]).unwrap();
741        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
742        assert_eq!(
743            err,
744            LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 1, 1)
745        );
746    }
747
748    #[test]
749    fn non_finite_trailing_submatrix_overflow_fused_branch_6d() {
750        // D > 5 uses the fused LDLT update path. The overflowing trailing
751        // diagonal is detected when it later becomes a pivot.
752        let mut rows = [[0.0; 6]; 6];
753        for (i, row) in rows.iter_mut().enumerate() {
754            row[i] = 1.0;
755        }
756        rows[0][5] = 1e200;
757        rows[5][0] = 1e200;
758
759        let a = Matrix::<6>::try_from_rows(rows).unwrap();
760        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
761        assert_eq!(
762            err,
763            LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 5, 5)
764        );
765    }
766
767    #[test]
768    fn non_finite_solve_forward_substitution_overflow() {
769        // SPD matrix with large L multiplier: L[1,0] = 1e153.
770        // Forward substitution overflows: y[1] = 0 - 1e153 * 1e156 = -inf.
771        let a = Matrix::<3>::try_from_rows([
772            [1.0, 1e153, 0.0],
773            [1e153, 1e306 + 1.0, 0.0],
774            [0.0, 0.0, 1.0],
775        ])
776        .unwrap();
777        let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
778
779        let b = Vector::<3>::new([1e156, 0.0, 0.0]);
780        let err = ldlt.solve(b).unwrap_err();
781        assert_eq!(
782            err,
783            LaError::non_finite_computation_step(ArithmeticOperation::LdltSolve, 1)
784        );
785    }
786
787    #[test]
788    fn non_finite_solve_back_substitution_overflow() {
789        // SPD matrix: [[1,0,0],[0,1,2],[0,2,5]] has LDLT factors
790        // D=[1,1,1], L[2,1]=2.  Forward sub and diagonal solve produce
791        // z=[0,0,1e308].  Back-substitution: x[2]=1e308 then
792        // x[1] = 0 - 2*1e308 = -inf (overflows f64).
793        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]])
794            .unwrap();
795        let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
796
797        let b = Vector::<3>::new([0.0, 0.0, 1e308]);
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_scatter_branch_5d() {
807        // Exercises the D >= 5 row-prefix scatter branch with the same
808        // bottom-right 2x2 SPD block used by the D3 back-substitution test.
809        let a = Matrix::<5>::try_from_rows([
810            [1.0, 0.0, 0.0, 0.0, 0.0],
811            [0.0, 1.0, 0.0, 0.0, 0.0],
812            [0.0, 0.0, 1.0, 0.0, 0.0],
813            [0.0, 0.0, 0.0, 1.0, 2.0],
814            [0.0, 0.0, 0.0, 2.0, 5.0],
815        ])
816        .unwrap();
817        let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
818
819        let b = Vector::<5>::new([0.0, 0.0, 0.0, 0.0, 1e308]);
820        let err = ldlt.solve(b).unwrap_err();
821        assert_eq!(
822            err,
823            LaError::non_finite_computation_step(ArithmeticOperation::LdltSolve, 3)
824        );
825    }
826
827    #[test]
828    fn non_finite_solve_diagonal_solve_overflow() {
829        // Diagonal SPD matrix with a tiny diagonal entry just above the
830        // singularity tolerance.  Forward substitution passes through the
831        // large RHS unchanged, then the diagonal solve z[1] = y[1] / D[1]
832        // = 1e300 / 1e-11 = 1e311 overflows f64, exercising the
833        // `!v.is_finite()` branch of the diagonal solve.
834        let a = Matrix::<2>::try_from_rows([[1.0, 0.0], [0.0, 1.0e-11]]).unwrap();
835        let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
836
837        let b = Vector::<2>::new([0.0, 1.0e300]);
838        let err = ldlt.solve(b).unwrap_err();
839        assert_eq!(
840            err,
841            LaError::non_finite_computation_step(ArithmeticOperation::LdltSolve, 1)
842        );
843    }
844
845    #[test]
846    fn det_rejects_product_overflow() {
847        let a = Matrix::<5>::try_from_rows([
848            [1.0e100, 0.0, 0.0, 0.0, 0.0],
849            [0.0, 1.0e100, 0.0, 0.0, 0.0],
850            [0.0, 0.0, 1.0e100, 0.0, 0.0],
851            [0.0, 0.0, 0.0, 1.0e100, 0.0],
852            [0.0, 0.0, 0.0, 0.0, 1.0e100],
853        ])
854        .unwrap();
855        let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
856        assert_eq!(
857            ldlt.det(),
858            Err(LaError::non_finite_computation_step(
859                ArithmeticOperation::Determinant,
860                4
861            ))
862        );
863    }
864
865    #[test]
866    fn det_balances_extreme_diagonals_independently_of_storage_order() {
867        let zero_tolerance = Tolerance::try_new(0.0).unwrap();
868        for diagonal in [
869            [TWO_NEG_800, TWO_NEG_800, TWO_POS_800, TWO_POS_800],
870            [TWO_POS_800, TWO_POS_800, TWO_NEG_800, TWO_NEG_800],
871        ] {
872            let mut rows = [[0.0; 4]; 4];
873            for (i, value) in diagonal.into_iter().enumerate() {
874                rows[i][i] = value;
875            }
876
877            let ldlt = Matrix::<4>::try_from_rows(rows)
878                .unwrap()
879                .ldlt(zero_tolerance)
880                .unwrap();
881            assert_eq!(ldlt.det(), Ok(1.0));
882        }
883    }
884
885    #[test]
886    fn det_balances_extreme_diagonals_in_large_dimension() {
887        let zero_tolerance = Tolerance::try_new(0.0).unwrap();
888        for diagonal in [
889            [TWO_NEG_800, TWO_NEG_800, TWO_POS_800, TWO_POS_800, 1.0, 1.0],
890            [TWO_POS_800, TWO_POS_800, TWO_NEG_800, TWO_NEG_800, 1.0, 1.0],
891        ] {
892            let mut rows = [[0.0; 6]; 6];
893            for (i, value) in diagonal.into_iter().enumerate() {
894                rows[i][i] = value;
895            }
896
897            let ldlt = Matrix::<6>::try_from_rows(rows)
898                .unwrap()
899                .ldlt(zero_tolerance)
900                .unwrap();
901            assert_eq!(ldlt.det(), Ok(1.0));
902        }
903    }
904
905    #[test]
906    fn det_rounds_final_tiny_magnitude_to_zero() {
907        let zero_tolerance = Tolerance::try_new(0.0).unwrap();
908        let matrix = Matrix::<2>::try_from_rows([[TWO_NEG_800, 0.0], [0.0, TWO_NEG_800]]).unwrap();
909        let det = matrix.ldlt(zero_tolerance).unwrap().det().unwrap();
910
911        assert_eq!(det.to_bits(), 0.0f64.to_bits());
912    }
913
914    #[test]
915    fn ldlt_d1_classifies_positive_zero_and_negative_inputs() {
916        let positive = Matrix::<1>::try_from_rows([[2.0]])
917            .unwrap()
918            .ldlt(DEFAULT_SINGULAR_TOL)
919            .unwrap();
920        assert_eq!(positive.det(), Ok(2.0));
921        assert_abs_diff_eq!(
922            positive
923                .solve(Vector::<1>::new([6.0]))
924                .unwrap()
925                .into_array()[0],
926            3.0,
927            epsilon = 0.0
928        );
929
930        let zero = Matrix::<1>::try_from_rows([[0.0]])
931            .unwrap()
932            .ldlt(DEFAULT_SINGULAR_TOL);
933        assert_eq!(
934            zero,
935            Err(LaError::singular_numerical(
936                0,
937                FactorizationKind::Ldlt,
938                0.0,
939                DEFAULT_SINGULAR_TOL.get()
940            ))
941        );
942
943        let negative = Matrix::<1>::try_from_rows([[-1.0]])
944            .unwrap()
945            .ldlt(DEFAULT_SINGULAR_TOL);
946        assert_eq!(
947            negative,
948            Err(LaError::not_positive_semidefinite_negative(0, -1.0))
949        );
950    }
951
952    /// Construct an exactly representable tridiagonal SPD system from a unit
953    /// lower-bidiagonal `L` and positive integer diagonal `D`.
954    fn nontrivial_spd_system<const D: usize>() -> (Matrix<D>, Vector<D>, [f64; D], f64) {
955        let mut rows = [[0.0_f64; D]; D];
956        let mut expected_det = 1.0_f64;
957        let mut diagonal = 1.0_f64;
958        let mut k = 0;
959        while k < D {
960            rows[k][k] += diagonal;
961            expected_det *= diagonal;
962            if k + 1 < D {
963                let off_diagonal = 0.5 * diagonal;
964                rows[k][k + 1] += off_diagonal;
965                rows[k + 1][k] += off_diagonal;
966                rows[k + 1][k + 1] = 0.25_f64.mul_add(diagonal, rows[k + 1][k + 1]);
967            }
968            diagonal += 1.0;
969            k += 1;
970        }
971
972        let mut expected_x = [0.0_f64; D];
973        let mut value = 1.0_f64;
974        for entry in &mut expected_x {
975            *entry = value;
976            value += 1.0;
977        }
978        let rhs = core::array::from_fn(|row| {
979            rows[row]
980                .iter()
981                .zip(expected_x.iter())
982                .fold(0.0_f64, |sum, (&coefficient, &x)| {
983                    coefficient.mul_add(x, sum)
984                })
985        });
986
987        (
988            Matrix::<D>::try_from_rows(rows).unwrap(),
989            Vector::<D>::try_new(rhs).unwrap(),
990            expected_x,
991            expected_det,
992        )
993    }
994
995    macro_rules! gen_nontrivial_large_ldlt_tests {
996        ($d:literal) => {
997            paste! {
998                #[test]
999                fn [<ldlt_nontrivial_success_solve_and_det_agree_ $d d>]() {
1000                    let (matrix, rhs, expected_x, expected_det) =
1001                        nontrivial_spd_system::<$d>();
1002                    let ldlt = matrix.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
1003                    let solution = ldlt.solve(rhs).unwrap().into_array();
1004
1005                    for (actual, expected) in solution.into_iter().zip(expected_x) {
1006                        assert_abs_diff_eq!(actual, expected, epsilon = 1e-12);
1007                    }
1008                    assert_abs_diff_eq!(ldlt.det().unwrap(), expected_det, epsilon = 1e-12);
1009                    assert_abs_diff_eq!(matrix.det().unwrap(), expected_det, epsilon = 1e-10);
1010                }
1011            }
1012        };
1013    }
1014
1015    gen_nontrivial_large_ldlt_tests!(6);
1016    gen_nontrivial_large_ldlt_tests!(8);
1017
1018    #[test]
1019    fn asymmetric_input_returns_typed_error() {
1020        // a[0][1] = 2.0 but a[1][0] = -2.0 → clearly asymmetric.
1021        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]])
1022            .unwrap();
1023        assert_eq!(
1024            a.ldlt(DEFAULT_SINGULAR_TOL),
1025            Err(LaError::asymmetric(0, 1, 3, 2.0, -2.0, 0.0))
1026        );
1027    }
1028
1029    #[test]
1030    fn approximately_symmetric_input_is_rejected_before_factoring_another_operator() {
1031        // The tolerance-based diagnostic accepts this exact power-of-two
1032        // counterexample because 4 <= 1e-12 * 2^43. Factoring only its lower
1033        // triangle would instead replace the upper zero with 4: the original
1034        // determinant is 32, while that projected matrix has determinant 16.
1035        let matrix = Matrix::<2>::try_from_rows([[TWO_POS_43, 0.0], [4.0, TWO_NEG_38]]).unwrap();
1036        let diagnostic_tolerance = Tolerance::try_new(1e-12).unwrap();
1037
1038        assert_eq!(matrix.det(), Ok(32.0));
1039        assert_eq!(matrix.is_symmetric(diagnostic_tolerance), Ok(true));
1040        assert_eq!(
1041            matrix.ldlt(DEFAULT_SINGULAR_TOL),
1042            Err(LaError::asymmetric(0, 1, 2, 0.0, 4.0, 0.0))
1043        );
1044    }
1045
1046    // -----------------------------------------------------------------------
1047    // Const-evaluability tests.
1048    //
1049    // These prove that `Ldlt::det` and `Ldlt::solve` are truly `const fn`
1050    // by forcing the compiler to evaluate them inside a `const` initializer.
1051    // `Ldlt::factor` is not (yet) `const fn` because the rank-1 update loop
1052    // uses array indexing patterns that still require non-const helpers on
1053    // some toolchains; we therefore construct `Ldlt<D>` directly.
1054    // -----------------------------------------------------------------------
1055
1056    macro_rules! gen_ldlt_const_eval_tests {
1057        ($d:literal) => {
1058            paste! {
1059                /// `Ldlt::det` must be fully const-evaluable. Setting
1060                /// `factors[0][0] = 2.0` and leaving the remaining identity
1061                /// diagonals at `1.0` gives `det = 2.0` for every `D ≥ 1`,
1062                /// exercising the multiply-accumulate loop at each dimension.
1063                #[test]
1064                fn [<ldlt_det_const_eval_ $d d>]() {
1065                    const DET: Result<f64, LaError> = {
1066                        let mut rows = [[0.0f64; $d]; $d];
1067                        let mut i = 0;
1068                        while i < $d {
1069                            rows[i][i] = 1.0;
1070                            i += 1;
1071                        }
1072                        rows[0][0] = 2.0;
1073                        let factors = LdltFactors::from_proven_rows(rows);
1074                        let ldlt = Ldlt::<$d> { factors };
1075                        ldlt.det()
1076                    };
1077                    assert_eq!(DET, Ok(2.0));
1078                }
1079
1080                /// `Ldlt::solve` must be fully const-evaluable. Identity
1081                /// factors with RHS `b = [1.0, 2.0, …, D]` round-trips `b`
1082                /// unchanged, exercising the full forward sub / diagonal solve
1083                /// / back sub pipeline inside a `const { … }` initializer.
1084                #[test]
1085                fn [<ldlt_solve_const_eval_ $d d>]() {
1086                    #[expect(
1087                        clippy::cast_precision_loss,
1088                        reason = "test indices are at most five and exactly representable as f64"
1089                    )]
1090                    const X: Result<Vector<$d>, LaError> = {
1091                        let factors = LdltFactors::from_proven_rows(
1092                            Matrix::<$d>::identity().into_rows()
1093                        );
1094                        let ldlt = Ldlt::<$d> { factors };
1095                        let mut b_arr = [0.0f64; $d];
1096                        let mut i = 0;
1097                        while i < $d {
1098                            b_arr[i] = i as f64 + 1.0;
1099                            i += 1;
1100                        }
1101                        let b = Vector::<$d>::new(b_arr);
1102                        ldlt.solve(b)
1103                    };
1104                    let x = X.unwrap().into_array();
1105                    #[expect(
1106                        clippy::cast_precision_loss,
1107                        reason = "test indices are at most five and exactly representable as f64"
1108                    )]
1109                    for i in 0..$d {
1110                        let expected = i as f64 + 1.0;
1111                        assert!((x[i] - expected).abs() <= 1e-12);
1112                    }
1113                }
1114            }
1115        };
1116    }
1117
1118    gen_ldlt_const_eval_tests!(2);
1119    gen_ldlt_const_eval_tests!(3);
1120    gen_ldlt_const_eval_tests!(4);
1121    gen_ldlt_const_eval_tests!(5);
1122}