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_eq!(
565            ldlt.solve(Vector::<0>::zero())
566                .unwrap()
567                .into_array()
568                .as_slice(),
569            []
570        );
571    }
572
573    #[test]
574    fn solve_2x2_known_spd() {
575        let a = Matrix::<2>::try_from_rows(black_box([[4.0, 2.0], [2.0, 3.0]])).unwrap();
576        let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
577
578        let b = Vector::<2>::new(black_box([1.0, 2.0]));
579        let x = ldlt.solve(b).unwrap().into_array();
580
581        assert_abs_diff_eq!(x[0], -0.125, epsilon = 1e-12);
582        assert_abs_diff_eq!(x[1], 0.75, epsilon = 1e-12);
583        assert_abs_diff_eq!(ldlt.det().unwrap(), 8.0, epsilon = 1e-12);
584    }
585
586    #[test]
587    fn det_ordinary_factors_matches_direct_product_bits() {
588        let diagonal = [1.5, 2.0, 0.25, 8.0];
589        let mut rows = [[0.0; 4]; 4];
590        let mut expected = 1.0;
591        for (i, factor) in diagonal.into_iter().enumerate() {
592            rows[i][i] = factor;
593            expected *= factor;
594        }
595
596        let ldlt = Matrix::<4>::try_from_rows(rows)
597            .unwrap()
598            .ldlt(DEFAULT_SINGULAR_TOL)
599            .unwrap();
600        assert_eq!(ldlt.det().unwrap().to_bits(), expected.to_bits());
601    }
602
603    #[test]
604    fn solve_3x3_spd_tridiagonal_smoke() {
605        let a = Matrix::<3>::try_from_rows(black_box([
606            [2.0, -1.0, 0.0],
607            [-1.0, 2.0, -1.0],
608            [0.0, -1.0, 2.0],
609        ]))
610        .unwrap();
611        let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
612
613        // Choose x = 1 so b = A x is simple: [1, 0, 1].
614        let b = Vector::<3>::new(black_box([1.0, 0.0, 1.0]));
615        let x = ldlt.solve(b).unwrap().into_array();
616
617        for &x_i in &x {
618            assert_abs_diff_eq!(x_i, 1.0, epsilon = 1e-9);
619        }
620    }
621
622    #[test]
623    fn singular_detected_for_degenerate_psd() {
624        // Rank-1 Gram-like matrix.
625        let a = Matrix::<2>::try_from_rows(black_box([[1.0, 1.0], [1.0, 1.0]])).unwrap();
626        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
627        assert_eq!(
628            err,
629            LaError::singular_numerical(
630                1,
631                FactorizationKind::Ldlt,
632                0.0,
633                DEFAULT_SINGULAR_TOL.get()
634            )
635        );
636    }
637
638    #[test]
639    fn zero_pivot_with_nonzero_coupling_is_not_reported_as_singular() {
640        let a = Matrix::<2>::try_from_rows(black_box([[0.0, 1.0], [1.0, 0.0]])).unwrap();
641        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
642        assert_eq!(
643            err,
644            LaError::not_positive_semidefinite_zero_coupling(0, 1, 1.0)
645        );
646    }
647
648    #[test]
649    fn zero_pivot_reports_non_finite_coupling_before_domain_violation() {
650        let a = Matrix::<3>::try_from_rows(black_box([
651            [1.0, 1.0, f64::MAX],
652            [1.0, 1.0, -f64::MAX],
653            [f64::MAX, -f64::MAX, 1.0],
654        ]))
655        .unwrap();
656        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
657        assert_eq!(
658            err,
659            LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 2, 1,)
660        );
661    }
662
663    #[test]
664    fn small_positive_pivot_reports_numerical_singularity() {
665        let a = Matrix::<2>::try_from_rows(black_box([[1e-13, 0.0], [0.0, 1.0]])).unwrap();
666        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
667        assert_eq!(
668            err,
669            LaError::singular_numerical(
670                0,
671                FactorizationKind::Ldlt,
672                1e-13,
673                DEFAULT_SINGULAR_TOL.get()
674            )
675        );
676    }
677
678    #[test]
679    fn small_positive_pivot_does_not_mask_earlier_non_finite_update() {
680        let a = Matrix::<3>::try_from_rows(black_box([
681            [1.0, 1.0, f64::MAX],
682            [1.0, 1.0 + f64::EPSILON, -f64::MAX],
683            [f64::MAX, -f64::MAX, 1.0],
684        ]))
685        .unwrap();
686
687        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
688        assert_eq!(
689            err,
690            LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 2, 1)
691        );
692    }
693
694    #[test]
695    fn negative_initial_diagonal_reports_not_positive_semidefinite() {
696        let a = Matrix::<2>::try_from_rows(black_box([[-1.0, 0.0], [0.0, 1.0]])).unwrap();
697        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
698        assert_eq!(err, LaError::not_positive_semidefinite_negative(0, -1.0));
699    }
700
701    #[test]
702    fn negative_updated_diagonal_reports_not_positive_semidefinite() {
703        let a = Matrix::<2>::try_from_rows(black_box([[1.0, 2.0], [2.0, 1.0]])).unwrap();
704        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
705        assert_eq!(err, LaError::not_positive_semidefinite_negative(1, -3.0));
706    }
707
708    #[test]
709    fn negative_pivot_does_not_mask_earlier_non_finite_update() {
710        let a = Matrix::<3>::try_from_rows(black_box([
711            [1.0, 2.0, f64::MAX],
712            [2.0, 1.0, 0.0],
713            [f64::MAX, 0.0, 1.0],
714        ]))
715        .unwrap();
716
717        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
718        assert_eq!(
719            err,
720            LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 2, 1,)
721        );
722    }
723
724    #[test]
725    fn non_finite_l_multiplier_overflow() {
726        // d = 1e-11 > tol, but l = 1e300 / 1e-11 = 1e311 overflows f64.
727        let a = Matrix::<2>::try_from_rows([[1e-11, 1e300], [1e300, 1.0]]).unwrap();
728        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
729        assert_eq!(
730            err,
731            LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 1, 0)
732        );
733    }
734
735    #[test]
736    fn non_finite_l_multiplier_overflow_fused_branch_6d() {
737        // D > 5 uses the fused LDLT update path. Keep the same overflow shape
738        // as the 2D test while forcing that branch.
739        let mut rows = [[0.0; 6]; 6];
740        for (i, row) in rows.iter_mut().enumerate() {
741            row[i] = 1.0;
742        }
743        rows[0][0] = 1e-11;
744        rows[0][5] = 1e300;
745        rows[5][0] = 1e300;
746
747        let a = Matrix::<6>::try_from_rows(rows).unwrap();
748        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
749        assert_eq!(
750            err,
751            LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 5, 0)
752        );
753    }
754
755    #[test]
756    fn non_finite_trailing_submatrix_overflow() {
757        // L multiplier is finite (1e200), but the rank-1 update
758        // (-1e200 * 1.0) * 1e200 + 1.0 overflows.
759        let a = Matrix::<2>::try_from_rows([[1.0, 1e200], [1e200, 1.0]]).unwrap();
760        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
761        assert_eq!(
762            err,
763            LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 1, 1)
764        );
765    }
766
767    #[test]
768    fn non_finite_trailing_submatrix_overflow_fused_branch_6d() {
769        // D > 5 uses the fused LDLT update path. The overflowing trailing
770        // diagonal is detected when it later becomes a pivot.
771        let mut rows = [[0.0; 6]; 6];
772        for (i, row) in rows.iter_mut().enumerate() {
773            row[i] = 1.0;
774        }
775        rows[0][5] = 1e200;
776        rows[5][0] = 1e200;
777
778        let a = Matrix::<6>::try_from_rows(rows).unwrap();
779        let err = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap_err();
780        assert_eq!(
781            err,
782            LaError::non_finite_computation_matrix(ArithmeticOperation::LdltFactorization, 5, 5)
783        );
784    }
785
786    #[test]
787    fn non_finite_solve_forward_substitution_overflow() {
788        // SPD matrix with large L multiplier: L[1,0] = 1e153.
789        // Forward substitution overflows: y[1] = 0 - 1e153 * 1e156 = -inf.
790        let a = Matrix::<3>::try_from_rows([
791            [1.0, 1e153, 0.0],
792            [1e153, 1e306 + 1.0, 0.0],
793            [0.0, 0.0, 1.0],
794        ])
795        .unwrap();
796        let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
797
798        let b = Vector::<3>::new([1e156, 0.0, 0.0]);
799        let err = ldlt.solve(b).unwrap_err();
800        assert_eq!(
801            err,
802            LaError::non_finite_computation_step(ArithmeticOperation::LdltSolve, 1)
803        );
804    }
805
806    #[test]
807    fn non_finite_solve_back_substitution_overflow() {
808        // SPD matrix: [[1,0,0],[0,1,2],[0,2,5]] has LDLT factors
809        // D=[1,1,1], L[2,1]=2.  Forward sub and diagonal solve produce
810        // z=[0,0,1e308].  Back-substitution: x[2]=1e308 then
811        // x[1] = 0 - 2*1e308 = -inf (overflows f64).
812        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]])
813            .unwrap();
814        let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
815
816        let b = Vector::<3>::new([0.0, 0.0, 1e308]);
817        let err = ldlt.solve(b).unwrap_err();
818        assert_eq!(
819            err,
820            LaError::non_finite_computation_step(ArithmeticOperation::LdltSolve, 1)
821        );
822    }
823
824    #[test]
825    fn non_finite_solve_back_substitution_overflow_scatter_branch_5d() {
826        // Exercises the D >= 5 row-prefix scatter branch with the same
827        // bottom-right 2x2 SPD block used by the D3 back-substitution test.
828        let a = Matrix::<5>::try_from_rows([
829            [1.0, 0.0, 0.0, 0.0, 0.0],
830            [0.0, 1.0, 0.0, 0.0, 0.0],
831            [0.0, 0.0, 1.0, 0.0, 0.0],
832            [0.0, 0.0, 0.0, 1.0, 2.0],
833            [0.0, 0.0, 0.0, 2.0, 5.0],
834        ])
835        .unwrap();
836        let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
837
838        let b = Vector::<5>::new([0.0, 0.0, 0.0, 0.0, 1e308]);
839        let err = ldlt.solve(b).unwrap_err();
840        assert_eq!(
841            err,
842            LaError::non_finite_computation_step(ArithmeticOperation::LdltSolve, 3)
843        );
844    }
845
846    #[test]
847    fn non_finite_solve_diagonal_solve_overflow() {
848        // Diagonal SPD matrix with a tiny diagonal entry just above the
849        // singularity tolerance.  Forward substitution passes through the
850        // large RHS unchanged, then the diagonal solve z[1] = y[1] / D[1]
851        // = 1e300 / 1e-11 = 1e311 overflows f64, exercising the
852        // `!v.is_finite()` branch of the diagonal solve.
853        let a = Matrix::<2>::try_from_rows([[1.0, 0.0], [0.0, 1.0e-11]]).unwrap();
854        let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
855
856        let b = Vector::<2>::new([0.0, 1.0e300]);
857        let err = ldlt.solve(b).unwrap_err();
858        assert_eq!(
859            err,
860            LaError::non_finite_computation_step(ArithmeticOperation::LdltSolve, 1)
861        );
862    }
863
864    #[test]
865    fn det_rejects_product_overflow() {
866        let a = Matrix::<5>::try_from_rows([
867            [1.0e100, 0.0, 0.0, 0.0, 0.0],
868            [0.0, 1.0e100, 0.0, 0.0, 0.0],
869            [0.0, 0.0, 1.0e100, 0.0, 0.0],
870            [0.0, 0.0, 0.0, 1.0e100, 0.0],
871            [0.0, 0.0, 0.0, 0.0, 1.0e100],
872        ])
873        .unwrap();
874        let ldlt = a.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
875        assert_eq!(
876            ldlt.det(),
877            Err(LaError::non_finite_computation_step(
878                ArithmeticOperation::Determinant,
879                4
880            ))
881        );
882    }
883
884    #[test]
885    fn det_balances_extreme_diagonals_independently_of_storage_order() {
886        let zero_tolerance = Tolerance::try_new(0.0).unwrap();
887        for diagonal in [
888            [TWO_NEG_800, TWO_NEG_800, TWO_POS_800, TWO_POS_800],
889            [TWO_POS_800, TWO_POS_800, TWO_NEG_800, TWO_NEG_800],
890        ] {
891            let mut rows = [[0.0; 4]; 4];
892            for (i, value) in diagonal.into_iter().enumerate() {
893                rows[i][i] = value;
894            }
895
896            let ldlt = Matrix::<4>::try_from_rows(rows)
897                .unwrap()
898                .ldlt(zero_tolerance)
899                .unwrap();
900            assert_eq!(ldlt.det(), Ok(1.0));
901        }
902    }
903
904    #[test]
905    fn det_balances_extreme_diagonals_in_large_dimension() {
906        let zero_tolerance = Tolerance::try_new(0.0).unwrap();
907        for diagonal in [
908            [TWO_NEG_800, TWO_NEG_800, TWO_POS_800, TWO_POS_800, 1.0, 1.0],
909            [TWO_POS_800, TWO_POS_800, TWO_NEG_800, TWO_NEG_800, 1.0, 1.0],
910        ] {
911            let mut rows = [[0.0; 6]; 6];
912            for (i, value) in diagonal.into_iter().enumerate() {
913                rows[i][i] = value;
914            }
915
916            let ldlt = Matrix::<6>::try_from_rows(rows)
917                .unwrap()
918                .ldlt(zero_tolerance)
919                .unwrap();
920            assert_eq!(ldlt.det(), Ok(1.0));
921        }
922    }
923
924    #[test]
925    fn det_rounds_final_tiny_magnitude_to_zero() {
926        let zero_tolerance = Tolerance::try_new(0.0).unwrap();
927        let matrix = Matrix::<2>::try_from_rows([[TWO_NEG_800, 0.0], [0.0, TWO_NEG_800]]).unwrap();
928        let det = matrix.ldlt(zero_tolerance).unwrap().det().unwrap();
929
930        assert_eq!(det.to_bits(), 0.0f64.to_bits());
931    }
932
933    #[test]
934    fn ldlt_d1_classifies_positive_zero_and_negative_inputs() {
935        let positive = Matrix::<1>::try_from_rows([[2.0]])
936            .unwrap()
937            .ldlt(DEFAULT_SINGULAR_TOL)
938            .unwrap();
939        assert_eq!(positive.det(), Ok(2.0));
940        assert_abs_diff_eq!(
941            positive
942                .solve(Vector::<1>::new([6.0]))
943                .unwrap()
944                .into_array()[0],
945            3.0,
946            epsilon = 0.0
947        );
948
949        let zero = Matrix::<1>::try_from_rows([[0.0]])
950            .unwrap()
951            .ldlt(DEFAULT_SINGULAR_TOL);
952        assert_eq!(
953            zero,
954            Err(LaError::singular_numerical(
955                0,
956                FactorizationKind::Ldlt,
957                0.0,
958                DEFAULT_SINGULAR_TOL.get()
959            ))
960        );
961
962        let negative = Matrix::<1>::try_from_rows([[-1.0]])
963            .unwrap()
964            .ldlt(DEFAULT_SINGULAR_TOL);
965        assert_eq!(
966            negative,
967            Err(LaError::not_positive_semidefinite_negative(0, -1.0))
968        );
969    }
970
971    /// Construct an exactly representable tridiagonal SPD system from a unit
972    /// lower-bidiagonal `L` and positive integer diagonal `D`.
973    fn nontrivial_spd_system<const D: usize>() -> (Matrix<D>, Vector<D>, [f64; D], f64) {
974        let mut rows = [[0.0_f64; D]; D];
975        let mut expected_det = 1.0_f64;
976        let mut diagonal = 1.0_f64;
977        let mut k = 0;
978        while k < D {
979            rows[k][k] += diagonal;
980            expected_det *= diagonal;
981            if k + 1 < D {
982                let off_diagonal = 0.5 * diagonal;
983                rows[k][k + 1] += off_diagonal;
984                rows[k + 1][k] += off_diagonal;
985                rows[k + 1][k + 1] = 0.25_f64.mul_add(diagonal, rows[k + 1][k + 1]);
986            }
987            diagonal += 1.0;
988            k += 1;
989        }
990
991        let mut expected_x = [0.0_f64; D];
992        let mut value = 1.0_f64;
993        for entry in &mut expected_x {
994            *entry = value;
995            value += 1.0;
996        }
997        let rhs = core::array::from_fn(|row| {
998            rows[row]
999                .iter()
1000                .zip(expected_x.iter())
1001                .fold(0.0_f64, |sum, (&coefficient, &x)| {
1002                    coefficient.mul_add(x, sum)
1003                })
1004        });
1005
1006        (
1007            Matrix::<D>::try_from_rows(rows).unwrap(),
1008            Vector::<D>::try_new(rhs).unwrap(),
1009            expected_x,
1010            expected_det,
1011        )
1012    }
1013
1014    macro_rules! gen_nontrivial_large_ldlt_tests {
1015        ($d:literal) => {
1016            paste! {
1017                #[test]
1018                fn [<ldlt_nontrivial_success_solve_and_det_agree_ $d d>]() {
1019                    let (matrix, rhs, expected_x, expected_det) =
1020                        nontrivial_spd_system::<$d>();
1021                    let ldlt = matrix.ldlt(DEFAULT_SINGULAR_TOL).unwrap();
1022                    let solution = ldlt.solve(rhs).unwrap().into_array();
1023
1024                    for (actual, expected) in solution.into_iter().zip(expected_x) {
1025                        assert_abs_diff_eq!(actual, expected, epsilon = 1e-12);
1026                    }
1027                    assert_abs_diff_eq!(ldlt.det().unwrap(), expected_det, epsilon = 1e-12);
1028                    assert_abs_diff_eq!(matrix.det().unwrap(), expected_det, epsilon = 1e-10);
1029                }
1030            }
1031        };
1032    }
1033
1034    gen_nontrivial_large_ldlt_tests!(6);
1035    gen_nontrivial_large_ldlt_tests!(8);
1036
1037    #[test]
1038    fn asymmetric_input_returns_typed_error() {
1039        // a[0][1] = 2.0 but a[1][0] = -2.0 → clearly asymmetric.
1040        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]])
1041            .unwrap();
1042        assert_eq!(
1043            a.ldlt(DEFAULT_SINGULAR_TOL),
1044            Err(LaError::asymmetric(0, 1, 3, 2.0, -2.0, 0.0))
1045        );
1046    }
1047
1048    #[test]
1049    fn approximately_symmetric_input_is_rejected_before_factoring_another_operator() {
1050        // The tolerance-based diagnostic accepts this exact power-of-two
1051        // counterexample because 4 <= 1e-12 * 2^43. Factoring only its lower
1052        // triangle would instead replace the upper zero with 4: the original
1053        // determinant is 32, while that projected matrix has determinant 16.
1054        let matrix = Matrix::<2>::try_from_rows([[TWO_POS_43, 0.0], [4.0, TWO_NEG_38]]).unwrap();
1055        let diagnostic_tolerance = Tolerance::try_new(1e-12).unwrap();
1056
1057        assert_eq!(matrix.det(), Ok(32.0));
1058        assert_eq!(matrix.is_symmetric(diagnostic_tolerance), Ok(true));
1059        assert_eq!(
1060            matrix.ldlt(DEFAULT_SINGULAR_TOL),
1061            Err(LaError::asymmetric(0, 1, 2, 0.0, 4.0, 0.0))
1062        );
1063    }
1064
1065    // -----------------------------------------------------------------------
1066    // Const-evaluability tests.
1067    //
1068    // These prove that `Ldlt::det` and `Ldlt::solve` are truly `const fn`
1069    // by forcing the compiler to evaluate them inside a `const` initializer.
1070    // `Ldlt::factor` is not (yet) `const fn` because the rank-1 update loop
1071    // uses array indexing patterns that still require non-const helpers on
1072    // some toolchains; we therefore construct `Ldlt<D>` directly.
1073    // -----------------------------------------------------------------------
1074
1075    macro_rules! gen_ldlt_const_eval_tests {
1076        ($d:literal) => {
1077            paste! {
1078                /// `Ldlt::det` must be fully const-evaluable. Setting
1079                /// `factors[0][0] = 2.0` and leaving the remaining identity
1080                /// diagonals at `1.0` gives `det = 2.0` for every `D ≥ 1`,
1081                /// exercising the multiply-accumulate loop at each dimension.
1082                #[test]
1083                fn [<ldlt_det_const_eval_ $d d>]() {
1084                    const DET: Result<f64, LaError> = {
1085                        let mut rows = [[0.0f64; $d]; $d];
1086                        let mut i = 0;
1087                        while i < $d {
1088                            rows[i][i] = 1.0;
1089                            i += 1;
1090                        }
1091                        rows[0][0] = 2.0;
1092                        let factors = LdltFactors::from_proven_rows(rows);
1093                        let ldlt = Ldlt::<$d> { factors };
1094                        ldlt.det()
1095                    };
1096                    assert_eq!(DET, Ok(2.0));
1097                }
1098
1099                /// `Ldlt::solve` must be fully const-evaluable. Identity
1100                /// factors with RHS `b = [1.0, 2.0, …, D]` round-trips `b`
1101                /// unchanged, exercising the full forward sub / diagonal solve
1102                /// / back sub pipeline inside a `const { … }` initializer.
1103                #[test]
1104                fn [<ldlt_solve_const_eval_ $d d>]() {
1105                    #[expect(
1106                        clippy::cast_precision_loss,
1107                        reason = "test indices are at most five and exactly representable as f64"
1108                    )]
1109                    const X: Result<Vector<$d>, LaError> = {
1110                        let factors = LdltFactors::from_proven_rows(
1111                            Matrix::<$d>::identity().into_rows()
1112                        );
1113                        let ldlt = Ldlt::<$d> { factors };
1114                        let mut b_arr = [0.0f64; $d];
1115                        let mut i = 0;
1116                        while i < $d {
1117                            b_arr[i] = i as f64 + 1.0;
1118                            i += 1;
1119                        }
1120                        let b = Vector::<$d>::new(b_arr);
1121                        ldlt.solve(b)
1122                    };
1123                    let x = X.unwrap().into_array();
1124                    #[expect(
1125                        clippy::cast_precision_loss,
1126                        reason = "test indices are at most five and exactly representable as f64"
1127                    )]
1128                    for i in 0..$d {
1129                        let expected = i as f64 + 1.0;
1130                        assert!((x[i] - expected).abs() <= 1e-12);
1131                    }
1132                }
1133            }
1134        };
1135    }
1136
1137    gen_ldlt_const_eval_tests!(2);
1138    gen_ldlt_const_eval_tests!(3);
1139    gen_ldlt_const_eval_tests!(4);
1140    gen_ldlt_const_eval_tests!(5);
1141}