Skip to main content

la_stack/
lu.rs

1#![forbid(unsafe_code)]
2
3//! LU decomposition and solves.
4//!
5//! The implementation computes `P A = L U` with partial pivoting. Partial
6//! pivoting is a practical finite-precision strategy rather than an
7//! unconditional accuracy guarantee; see `REFERENCES.md` \[1-3, 11-12\] for
8//! stability analysis and standard algorithmic background.
9
10use core::hint::cold_path;
11
12use crate::matrix::Matrix;
13use crate::scaled_product::{RangeCheckedProduct, ScaledProduct, range_checked_product};
14use crate::vector::Vector;
15use crate::{ArithmeticOperation, FactorizationKind, LaError, Tolerance};
16
17/// LU decomposition (PA = LU) with partial pivoting.
18///
19/// `Lu<0>` represents the empty factorization. Its determinant is the empty
20/// product `1.0`, and solving against [`Vector<0>`] returns [`Vector<0>`].
21/// Numerical solves and determinants remain subject to binary64 rounding and
22/// matrix conditioning; this type does not provide a certified error bound.
23#[must_use]
24#[derive(Clone, Copy, Debug, PartialEq)]
25pub struct Lu<const D: usize> {
26    factors: LuFactors<D>,
27    permutation: RowPermutation<D>,
28}
29
30/// Finite LU factor storage.
31///
32/// [`Lu::factor_finite`] separately proves that every `U[i,i]` satisfies the
33/// factorization tolerance before this storage becomes part of a [`Lu`].
34#[derive(Clone, Copy, Debug, PartialEq)]
35struct LuFactors<const D: usize> {
36    storage: [[f64; D]; D],
37}
38
39impl<const D: usize> LuFactors<D> {
40    /// Validate and finalize raw factorization work storage as finite factors.
41    #[inline]
42    const fn try_from_computation(storage: [[f64; D]; D]) -> Result<Self, LaError> {
43        let mut row = 0;
44        while row < D {
45            let mut col = 0;
46            while col < D {
47                if !storage[row][col].is_finite() {
48                    return Err(LaError::non_finite_computation_matrix(
49                        ArithmeticOperation::LuFactorization,
50                        row,
51                        col,
52                    ));
53                }
54                col += 1;
55            }
56            row += 1;
57        }
58
59        Ok(Self { storage })
60    }
61
62    /// Borrow a factor row.
63    #[inline]
64    #[must_use]
65    const fn row(&self, index: usize) -> &[f64; D] {
66        &self.storage[index]
67    }
68
69    /// Return a diagonal entry of `U`.
70    #[inline]
71    #[must_use]
72    const fn diag(&self, index: usize) -> f64 {
73        self.storage[index][index]
74    }
75}
76
77/// Source-row permutation and its determinant parity.
78///
79/// Starting from identity and permitting only synchronized swaps makes every
80/// stored source row in-bounds and unique while keeping parity inseparable from
81/// the index mapping.
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83struct RowPermutation<const D: usize> {
84    source_rows: [usize; D],
85    odd: bool,
86}
87
88impl<const D: usize> RowPermutation<D> {
89    /// Construct the identity permutation.
90    const fn identity() -> Self {
91        let mut source_rows = [0; D];
92        let mut row = 0;
93        while row < D {
94            source_rows[row] = row;
95            row += 1;
96        }
97        Self {
98            source_rows,
99            odd: false,
100        }
101    }
102
103    /// Apply one row swap and update parity atomically.
104    const fn swap(&mut self, left: usize, right: usize) {
105        if left != right {
106            let source_row = self.source_rows[left];
107            self.source_rows[left] = self.source_rows[right];
108            self.source_rows[right] = source_row;
109            self.odd = !self.odd;
110        }
111    }
112
113    /// Return the original source row now occupying `row`.
114    const fn source_row(&self, row: usize) -> usize {
115        self.source_rows[row]
116    }
117
118    /// Return whether the permutation contains an odd number of swaps.
119    const fn is_odd(&self) -> bool {
120        self.odd
121    }
122}
123
124impl<const D: usize> Lu<D> {
125    /// Factor a finite square matrix into in-place LU storage for
126    /// [`Matrix::lu`].
127    ///
128    /// The input has already proven finite entries, so LU construction rejects
129    /// numerically singular pivots and non-finite elimination intermediates
130    /// before callers can observe a [`Lu`] value. Completed factor storage is
131    /// checked before return so successful factors do not contain a non-finite
132    /// value produced during elimination.
133    #[inline]
134    pub(crate) fn factor_finite(a: Matrix<D>, tol: Tolerance) -> Result<Self, LaError> {
135        let mut rows = a.into_rows();
136        let tolerance = tol.get();
137        let mut permutation = RowPermutation::identity();
138
139        {
140            let rows = &mut rows;
141
142            for k in 0..D {
143                // Choose pivot row.
144                let mut pivot_row = k;
145                let mut pivot_abs = rows[k][k].abs();
146
147                #[expect(
148                    clippy::needless_range_loop,
149                    reason = "the row index identifies the pivot later used for synchronized matrix and permutation swaps"
150                )]
151                for r in (k + 1)..D {
152                    let v = rows[r][k].abs();
153                    if v > pivot_abs {
154                        pivot_abs = v;
155                        pivot_row = r;
156                    }
157                }
158
159                if pivot_abs <= tolerance {
160                    cold_path();
161
162                    // A non-finite value produced in an earlier update does not
163                    // participate in `v > pivot_abs` comparisons. Scan only on
164                    // this cold failure path so it cannot be masked as singular.
165                    for (row, values) in rows.iter().enumerate() {
166                        for (col, value) in values.iter().enumerate() {
167                            if !value.is_finite() {
168                                return Err(LaError::non_finite_computation_matrix(
169                                    ArithmeticOperation::LuFactorization,
170                                    row,
171                                    col,
172                                ));
173                            }
174                        }
175                    }
176
177                    return Err(LaError::singular_numerical(
178                        k,
179                        FactorizationKind::Lu,
180                        pivot_abs,
181                        tolerance,
182                    ));
183                }
184
185                if pivot_row != k {
186                    rows.swap(k, pivot_row);
187                    permutation.swap(k, pivot_row);
188                }
189
190                let pivot = rows[k][k];
191
192                // Eliminate below pivot.
193                for r in (k + 1)..D {
194                    let mult = rows[r][k] / pivot;
195                    rows[r][k] = mult;
196
197                    #[expect(
198                        clippy::needless_range_loop,
199                        reason = "the column index pairs pivot-row reads with eliminated-row writes in the in-place update"
200                    )]
201                    for c in (k + 1)..D {
202                        let updated = (-mult).mul_add(rows[k][c], rows[r][c]);
203                        rows[r][c] = updated;
204                    }
205                }
206            }
207        }
208
209        let factors = LuFactors::try_from_computation(rows)?;
210
211        Ok(Self {
212            factors,
213            permutation,
214        })
215    }
216
217    /// Solve `A x = b` using this LU factorization.
218    ///
219    /// [`Vector`] is finite by construction, so this method only checks computed
220    /// substitution overflows. It performs floating-point forward/back
221    /// substitution and does not provide a certified absolute rounding-error
222    /// bound for the returned solution.
223    ///
224    /// # Examples
225    /// ```
226    /// use la_stack::prelude::*;
227    ///
228    /// # fn main() -> Result<(), LaError> {
229    /// let a = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]])?;
230    /// let lu = a.lu(DEFAULT_SINGULAR_TOL)?;
231    ///
232    /// let b = Vector::<2>::try_new([5.0, 11.0])?;
233    /// let x = lu.solve(b)?.into_array();
234    ///
235    /// assert!((x[0] - 1.0).abs() <= 1e-12);
236    /// assert!((x[1] - 2.0).abs() <= 1e-12);
237    /// # Ok(())
238    /// # }
239    /// ```
240    ///
241    /// # Errors
242    /// Returns [`LaError::NonFinite`] if a computed substitution intermediate
243    /// overflows to NaN or infinity.
244    #[inline]
245    pub const fn solve(&self, b: Vector<D>) -> Result<Vector<D>, LaError> {
246        let mut x = [0.0; D];
247        let b = b.as_array();
248        let mut i = 0;
249
250        if D <= 4 {
251            while i < D {
252                x[i] = b[self.permutation.source_row(i)];
253                i += 1;
254            }
255
256            // Tiny matrices benchmark better when pivoted RHS materialization
257            // stays separate from forward substitution.
258            i = 0;
259            while i < D {
260                let mut sum = x[i];
261                let row = self.factors.row(i);
262                let mut j = 0;
263                while j < i {
264                    sum = (-row[j]).mul_add(x[j], sum);
265                    j += 1;
266                }
267                if !sum.is_finite() {
268                    cold_path();
269                    return Err(LaError::non_finite_computation_step(
270                        ArithmeticOperation::LuSolve,
271                        i,
272                    ));
273                }
274                x[i] = sum;
275                i += 1;
276            }
277        } else {
278            // Larger fixed dimensions avoid an extra pass by reading the
279            // pivoted right-hand side directly into forward substitution.
280            while i < D {
281                let mut sum = b[self.permutation.source_row(i)];
282                let row = self.factors.row(i);
283                let mut j = 0;
284                while j < i {
285                    sum = (-row[j]).mul_add(x[j], sum);
286                    j += 1;
287                }
288                if !sum.is_finite() {
289                    cold_path();
290                    return Err(LaError::non_finite_computation_step(
291                        ArithmeticOperation::LuSolve,
292                        i,
293                    ));
294                }
295                x[i] = sum;
296                i += 1;
297            }
298        }
299
300        // Back substitution for U.
301        let mut ii = 0;
302        while ii < D {
303            let i = D - 1 - ii;
304            let mut sum = x[i];
305            let row = self.factors.row(i);
306            let mut j = i + 1;
307            while j < D {
308                sum = (-row[j]).mul_add(x[j], sum);
309                j += 1;
310            }
311
312            let diag = row[i];
313            if !sum.is_finite() {
314                cold_path();
315                return Err(LaError::non_finite_computation_step(
316                    ArithmeticOperation::LuSolve,
317                    i,
318                ));
319            }
320
321            let quotient = sum / diag;
322            if !quotient.is_finite() {
323                cold_path();
324                return Err(LaError::non_finite_computation_step(
325                    ArithmeticOperation::LuSolve,
326                    i,
327                ));
328            }
329            x[i] = quotient;
330            ii += 1;
331        }
332
333        Vector::from_computation(x, ArithmeticOperation::LuSolve)
334    }
335
336    /// Determinant of the original matrix.
337    ///
338    /// # Examples
339    /// ```
340    /// use la_stack::prelude::*;
341    ///
342    /// # fn main() -> Result<(), LaError> {
343    /// let a = Matrix::<2>::try_from_rows([[1.0, 2.0], [3.0, 4.0]])?;
344    /// let lu = a.lu(DEFAULT_SINGULAR_TOL)?;
345    ///
346    /// let det = lu.det()?;
347    /// assert!((det - (-2.0)).abs() <= 1e-12);
348    /// # Ok(())
349    /// # }
350    /// ```
351    ///
352    /// Diagonal pivots are multiplied directly while each non-zero running
353    /// product remains finite and normal. If direct accumulation detects range
354    /// loss, all pivots are recomputed with power-of-two scaling before a
355    /// premature overflow or underflow can affect the returned determinant.
356    /// The final product is rounded to `f64`; a non-zero magnitude below the
357    /// binary64 range may round to zero. No certified absolute error bound is
358    /// provided.
359    ///
360    /// # Errors
361    /// Returns [`LaError::NonFinite`] if the final scaled determinant cannot be
362    /// represented as a finite `f64`.
363    #[inline]
364    pub const fn det(&self) -> Result<f64, LaError> {
365        let mut det = if self.permutation.is_odd() { -1.0 } else { 1.0 };
366        let mut i = 0;
367        while i < D {
368            let factor = self.factors.diag(i);
369            match range_checked_product(det, factor) {
370                RangeCheckedProduct::Safe(next) => det = next,
371                RangeCheckedProduct::NeedsScaling => {
372                    cold_path();
373                    return self.scaled_det();
374                }
375            }
376            i += 1;
377        }
378        Ok(det)
379    }
380
381    /// Recompute the determinant with normalized mantissa/exponent scaling.
382    #[cold]
383    const fn scaled_det(&self) -> Result<f64, LaError> {
384        let mut product = ScaledProduct::new(self.permutation.is_odd());
385        let mut i = 0;
386        while i < D {
387            product.multiply(self.factors.diag(i));
388            i += 1;
389        }
390
391        if let Some(det) = product.finish() {
392            Ok(det)
393        } else {
394            Err(LaError::non_finite_computation_step(
395                ArithmeticOperation::Determinant,
396                D.saturating_sub(1),
397            ))
398        }
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use core::hint::black_box;
405
406    use approx::assert_abs_diff_eq;
407    use pastey::paste;
408
409    use super::*;
410    use crate::DEFAULT_SINGULAR_TOL;
411
412    const TWO_NEG_800: f64 = f64::from_bits(223_u64 << 52);
413    const TWO_POS_800: f64 = f64::from_bits(1823_u64 << 52);
414
415    #[test]
416    fn row_permutation_keeps_mapping_and_parity_synchronized() {
417        let mut permutation = RowPermutation::<4>::identity();
418        assert_eq!(
419            core::array::from_fn(|row| permutation.source_row(row)),
420            [0, 1, 2, 3]
421        );
422        assert!(!permutation.is_odd());
423
424        permutation.swap(0, 3);
425        assert_eq!(
426            core::array::from_fn(|row| permutation.source_row(row)),
427            [3, 1, 2, 0]
428        );
429        assert!(permutation.is_odd());
430
431        permutation.swap(1, 2);
432        assert_eq!(
433            core::array::from_fn(|row| permutation.source_row(row)),
434            [3, 2, 1, 0]
435        );
436        assert!(!permutation.is_odd());
437    }
438
439    macro_rules! gen_pivoting_solve_and_det_tests {
440        ($d:literal) => {
441            paste! {
442                #[test]
443                fn [<lu_solve_pivoting_ $d d>]() {
444                    // Public API path under test:
445                    // Matrix::lu (pub) -> Lu::solve (pub).
446
447                    // Permutation matrix that swaps the first two basis vectors.
448                    // This forces pivoting in column 0 for any D >= 2.
449                    let mut rows = [[0.0f64; $d]; $d];
450                    for i in 0..$d {
451                        rows[i][i] = 1.0;
452                    }
453                    rows.swap(0, 1);
454
455                    let a = Matrix::<$d>::try_from_rows(black_box(rows)).unwrap();
456                    let lu_fn: fn(Matrix<$d>, Tolerance) -> Result<Lu<$d>, LaError> =
457                        black_box(Matrix::<$d>::lu);
458                    let lu = lu_fn(a, DEFAULT_SINGULAR_TOL).unwrap();
459
460                    // Pick a simple RHS with unique entries, so the expected swap is obvious.
461                    let b_arr = {
462                        let mut arr = [0.0f64; $d];
463                        let mut val = 1.0f64;
464                        for dst in arr.iter_mut() {
465                            *dst = val;
466                            val += 1.0;
467                        }
468                        arr
469                    };
470                    let mut expected = b_arr;
471                    expected.swap(0, 1);
472                    let b = Vector::<$d>::new(black_box(b_arr));
473
474                    let solve_fn: fn(&Lu<$d>, Vector<$d>) -> Result<Vector<$d>, LaError> =
475                        black_box(Lu::<$d>::solve);
476                    let x = solve_fn(&lu, b).unwrap().into_array();
477
478                    for i in 0..$d {
479                        assert_abs_diff_eq!(x[i], expected[i], epsilon = 1e-12);
480                    }
481                }
482
483                #[test]
484                fn [<lu_det_pivoting_ $d d>]() {
485                    // Public API path under test:
486                    // Matrix::lu (pub) -> Lu::det (pub).
487
488                    // Permutation matrix that swaps the first two basis vectors.
489                    let mut rows = [[0.0f64; $d]; $d];
490                    for i in 0..$d {
491                        rows[i][i] = 1.0;
492                    }
493                    rows.swap(0, 1);
494
495                    let a = Matrix::<$d>::try_from_rows(black_box(rows)).unwrap();
496                    let lu_fn: fn(Matrix<$d>, Tolerance) -> Result<Lu<$d>, LaError> =
497                        black_box(Matrix::<$d>::lu);
498                    let lu = lu_fn(a, DEFAULT_SINGULAR_TOL).unwrap();
499
500                    // Row swap ⇒ determinant sign flip.
501                    let det_fn: fn(&Lu<$d>) -> Result<f64, LaError> =
502                        black_box(Lu::<$d>::det);
503                    assert_abs_diff_eq!(det_fn(&lu).unwrap(), -1.0, epsilon = 1e-12);
504                }
505            }
506        };
507    }
508
509    gen_pivoting_solve_and_det_tests!(2);
510    gen_pivoting_solve_and_det_tests!(3);
511    gen_pivoting_solve_and_det_tests!(4);
512    gen_pivoting_solve_and_det_tests!(5);
513
514    macro_rules! gen_tridiagonal_smoke_solve_and_det_tests {
515        ($d:literal $(, #[$stack_array_expectation:meta])?) => {
516            paste! {
517                #[test]
518                fn [<lu_solve_tridiagonal_smoke_ $d d>]() {
519                    // Public API path under test:
520                    // Matrix::lu (pub) -> Lu::solve (pub).
521
522                    // Classic SPD tridiagonal: 2 on diagonal, -1 on sub/super-diagonals.
523                    $(#[$stack_array_expectation])?
524                    let mut rows = [[0.0f64; $d]; $d];
525                    for i in 0..$d {
526                        rows[i][i] = 2.0;
527                        if i > 0 {
528                            rows[i][i - 1] = -1.0;
529                        }
530                        if i + 1 < $d {
531                            rows[i][i + 1] = -1.0;
532                        }
533                    }
534
535                    let a = Matrix::<$d>::try_from_rows(black_box(rows)).unwrap();
536                    let lu_fn: fn(Matrix<$d>, Tolerance) -> Result<Lu<$d>, LaError> =
537                        black_box(Matrix::<$d>::lu);
538                    let lu = lu_fn(a, DEFAULT_SINGULAR_TOL).unwrap();
539
540                    // Choose x = 1, so b = A x is simple: [1, 0, 0, ..., 0, 1].
541                    let mut b_arr = [0.0f64; $d];
542                    b_arr[0] = 1.0;
543                    b_arr[$d - 1] = 1.0;
544                    let b = Vector::<$d>::new(black_box(b_arr));
545
546                    let solve_fn: fn(&Lu<$d>, Vector<$d>) -> Result<Vector<$d>, LaError> =
547                        black_box(Lu::<$d>::solve);
548                    let x = solve_fn(&lu, b).unwrap().into_array();
549
550                    for &x_i in &x {
551                        assert_abs_diff_eq!(x_i, 1.0, epsilon = 1e-9);
552                    }
553                }
554
555                #[test]
556                fn [<lu_det_tridiagonal_smoke_ $d d>]() {
557                    // Public API path under test:
558                    // Matrix::lu (pub) -> Lu::det (pub).
559
560                    // Classic SPD tridiagonal: 2 on diagonal, -1 on sub/super-diagonals.
561                    // Determinant is known exactly: det = D + 1.
562                    $(#[$stack_array_expectation])?
563                    let mut rows = [[0.0f64; $d]; $d];
564                    for i in 0..$d {
565                        rows[i][i] = 2.0;
566                        if i > 0 {
567                            rows[i][i - 1] = -1.0;
568                        }
569                        if i + 1 < $d {
570                            rows[i][i + 1] = -1.0;
571                        }
572                    }
573
574                    let a = Matrix::<$d>::try_from_rows(black_box(rows)).unwrap();
575                    let lu_fn: fn(Matrix<$d>, Tolerance) -> Result<Lu<$d>, LaError> =
576                        black_box(Matrix::<$d>::lu);
577                    let lu = lu_fn(a, DEFAULT_SINGULAR_TOL).unwrap();
578
579                    let det_fn: fn(&Lu<$d>) -> Result<f64, LaError> =
580                        black_box(Lu::<$d>::det);
581                    assert_abs_diff_eq!(det_fn(&lu).unwrap(), f64::from($d) + 1.0, epsilon = 1e-8);
582                }
583            }
584        };
585    }
586
587    gen_tridiagonal_smoke_solve_and_det_tests!(16);
588    gen_tridiagonal_smoke_solve_and_det_tests!(32);
589    gen_tridiagonal_smoke_solve_and_det_tests!(
590        64,
591        #[expect(
592            clippy::large_stack_arrays,
593            reason = "the test deliberately exercises the crate's stack-allocated matrix storage"
594        )]
595    );
596
597    #[test]
598    fn solve_0x0_returns_empty_vector_and_unit_det() {
599        let a = Matrix::<0>::zero();
600        let lu = a.lu(DEFAULT_SINGULAR_TOL).unwrap();
601
602        assert_eq!(lu.det(), Ok(1.0));
603        assert!(
604            lu.solve(Vector::<0>::zero())
605                .unwrap()
606                .into_array()
607                .is_empty()
608        );
609    }
610
611    #[test]
612    fn solve_1x1() {
613        let a = Matrix::<1>::try_from_rows(black_box([[2.0]])).unwrap();
614        let lu = a.lu(DEFAULT_SINGULAR_TOL).unwrap();
615
616        let b = Vector::<1>::new(black_box([6.0]));
617        let solve_fn: fn(&Lu<1>, Vector<1>) -> Result<Vector<1>, LaError> =
618            black_box(Lu::<1>::solve);
619        let x = solve_fn(&lu, b).unwrap().into_array();
620        assert_abs_diff_eq!(x[0], 3.0, epsilon = 1e-12);
621
622        let det_fn: fn(&Lu<1>) -> Result<f64, LaError> = black_box(Lu::<1>::det);
623        assert_abs_diff_eq!(det_fn(&lu).unwrap(), 2.0, epsilon = 0.0);
624    }
625
626    #[test]
627    fn solve_2x2_basic() {
628        let a = Matrix::<2>::try_from_rows(black_box([[1.0, 2.0], [3.0, 4.0]])).unwrap();
629        let lu = a.lu(DEFAULT_SINGULAR_TOL).unwrap();
630        let b = Vector::<2>::new(black_box([5.0, 11.0]));
631
632        let solve_fn: fn(&Lu<2>, Vector<2>) -> Result<Vector<2>, LaError> =
633            black_box(Lu::<2>::solve);
634        let x = solve_fn(&lu, b).unwrap().into_array();
635
636        assert_abs_diff_eq!(x[0], 1.0, epsilon = 1e-12);
637        assert_abs_diff_eq!(x[1], 2.0, epsilon = 1e-12);
638    }
639
640    #[test]
641    fn det_2x2_basic() {
642        let a = Matrix::<2>::try_from_rows(black_box([[1.0, 2.0], [3.0, 4.0]])).unwrap();
643        let lu = a.lu(DEFAULT_SINGULAR_TOL).unwrap();
644
645        let det_fn: fn(&Lu<2>) -> Result<f64, LaError> = black_box(Lu::<2>::det);
646        assert_abs_diff_eq!(det_fn(&lu).unwrap(), -2.0, epsilon = 1e-12);
647    }
648
649    #[test]
650    fn det_ordinary_factors_matches_direct_product_bits() {
651        let diagonal = [1.5, -2.0, 0.25, 8.0];
652        let mut rows = [[0.0; 4]; 4];
653        let mut expected = 1.0;
654        for (i, factor) in diagonal.into_iter().enumerate() {
655            rows[i][i] = factor;
656            expected *= factor;
657        }
658
659        let lu = Matrix::<4>::try_from_rows(rows)
660            .unwrap()
661            .lu(DEFAULT_SINGULAR_TOL)
662            .unwrap();
663        assert_eq!(lu.det().unwrap().to_bits(), expected.to_bits());
664    }
665
666    #[test]
667    fn singular_detected() {
668        let a = Matrix::<2>::try_from_rows(black_box([[1.0, 2.0], [2.0, 4.0]])).unwrap();
669        let err = a.lu(DEFAULT_SINGULAR_TOL).unwrap_err();
670        assert_eq!(
671            err,
672            LaError::singular_numerical(1, FactorizationKind::Lu, 0.0, DEFAULT_SINGULAR_TOL.get())
673        );
674    }
675
676    #[test]
677    fn singular_due_to_tolerance_at_first_pivot() {
678        // Not exactly singular, but below DEFAULT_SINGULAR_TOL.
679        let a = Matrix::<2>::try_from_rows(black_box([[1e-13, 0.0], [0.0, 1.0]])).unwrap();
680        let err = a.lu(DEFAULT_SINGULAR_TOL).unwrap_err();
681        assert_eq!(
682            err,
683            LaError::singular_numerical(
684                0,
685                FactorizationKind::Lu,
686                1e-13,
687                DEFAULT_SINGULAR_TOL.get()
688            )
689        );
690    }
691
692    #[test]
693    fn non_finite_detected_in_trailing_update() {
694        let a = Matrix::<3>::try_from_rows([
695            [1.0, f64::MAX, 0.0],
696            [-1.0, f64::MAX, 0.0],
697            [0.0, 0.0, 1.0],
698        ])
699        .unwrap();
700
701        let err = a.lu(DEFAULT_SINGULAR_TOL).unwrap_err();
702        assert_eq!(
703            err,
704            LaError::non_finite_computation_matrix(ArithmeticOperation::LuFactorization, 1, 1)
705        );
706    }
707
708    #[test]
709    fn generated_non_finite_takes_precedence_over_later_singular_pivot() {
710        // The first update generates infinities, and the next generates NaN.
711        // NaN does not win a pivot comparison and must not be masked as singular.
712        let a = Matrix::<4>::try_from_rows([
713            [1.0, f64::MAX, 0.0, 0.0],
714            [1.0, f64::MAX, 0.0, 0.0],
715            [-1.0, f64::MAX, 0.0, 0.0],
716            [-1.0, f64::MAX, 0.0, 0.0],
717        ])
718        .unwrap();
719
720        let err = a.lu(DEFAULT_SINGULAR_TOL).unwrap_err();
721        assert_eq!(
722            err,
723            LaError::non_finite_computation_matrix(ArithmeticOperation::LuFactorization, 1, 1,)
724        );
725    }
726
727    #[test]
728    fn solve_non_finite_forward_substitution_overflow() {
729        // L has a -1 multiplier, and a large RHS makes forward substitution overflow.
730        let a = Matrix::<3>::try_from_rows([[1.0, 0.0, 0.0], [-1.0, 1.0, 0.0], [0.0, 0.0, 1.0]])
731            .unwrap();
732        let lu = a.lu(DEFAULT_SINGULAR_TOL).unwrap();
733
734        let b = Vector::<3>::new([1.0e308, 1.0e308, 0.0]);
735        let err = lu.solve(b).unwrap_err();
736        assert_eq!(
737            err,
738            LaError::non_finite_computation_step(ArithmeticOperation::LuSolve, 1)
739        );
740    }
741
742    #[test]
743    fn solve_non_finite_forward_substitution_overflow_fused_branch_5d() {
744        // Exercises the D >= 5 fused pivot/forward-substitution branch with the
745        // same overflowing L multiplier as the D3 test.
746        let a = Matrix::<5>::try_from_rows([
747            [1.0, 0.0, 0.0, 0.0, 0.0],
748            [-1.0, 1.0, 0.0, 0.0, 0.0],
749            [0.0, 0.0, 1.0, 0.0, 0.0],
750            [0.0, 0.0, 0.0, 1.0, 0.0],
751            [0.0, 0.0, 0.0, 0.0, 1.0],
752        ])
753        .unwrap();
754        let lu = a.lu(DEFAULT_SINGULAR_TOL).unwrap();
755
756        let b = Vector::<5>::new([1.0e308, 1.0e308, 0.0, 0.0, 0.0]);
757        let err = lu.solve(b).unwrap_err();
758        assert_eq!(
759            err,
760            LaError::non_finite_computation_step(ArithmeticOperation::LuSolve, 1)
761        );
762    }
763
764    #[test]
765    fn solve_non_finite_back_substitution_overflow() {
766        // Make x[1] overflow during back substitution, then ensure it is detected on the next row.
767        let a = Matrix::<2>::try_from_rows([[1.0, 1.0], [0.0, 2.0e-12]]).unwrap();
768        let lu = a.lu(DEFAULT_SINGULAR_TOL).unwrap();
769
770        let b = Vector::<2>::new([0.0, 1.0e300]);
771        let err = lu.solve(b).unwrap_err();
772        assert_eq!(
773            err,
774            LaError::non_finite_computation_step(ArithmeticOperation::LuSolve, 1)
775        );
776    }
777
778    #[test]
779    fn solve_non_finite_back_substitution_sum_overflow() {
780        // Upper-triangular U with a very large off-diagonal in row 1 and a
781        // very large x[2] produced by the RHS.  The back-substitution
782        // accumulator `sum = (-row[j]).mul_add(x[j], sum)` overflows while
783        // reducing row 1, so the failure is detected via the `!sum.is_finite()`
784        // branch of the combined diag/sum check (distinct from the
785        // `q = sum / diag` overflow path covered above).
786        let a = Matrix::<3>::try_from_rows([[1.0, 0.0, 0.0], [0.0, 1.0, 1.0e200], [0.0, 0.0, 1.0]])
787            .unwrap();
788        let lu = a.lu(DEFAULT_SINGULAR_TOL).unwrap();
789
790        let b = Vector::<3>::new([0.0, 0.0, 1.0e200]);
791        let err = lu.solve(b).unwrap_err();
792        assert_eq!(
793            err,
794            LaError::non_finite_computation_step(ArithmeticOperation::LuSolve, 1)
795        );
796    }
797
798    #[test]
799    fn det_rejects_product_overflow() {
800        let a = Matrix::<5>::try_from_rows([
801            [1.0e100, 0.0, 0.0, 0.0, 0.0],
802            [0.0, 1.0e100, 0.0, 0.0, 0.0],
803            [0.0, 0.0, 1.0e100, 0.0, 0.0],
804            [0.0, 0.0, 0.0, 1.0e100, 0.0],
805            [0.0, 0.0, 0.0, 0.0, 1.0e100],
806        ])
807        .unwrap();
808        let lu = a.lu(DEFAULT_SINGULAR_TOL).unwrap();
809        assert_eq!(
810            lu.det(),
811            Err(LaError::non_finite_computation_step(
812                ArithmeticOperation::Determinant,
813                4
814            ))
815        );
816    }
817
818    #[test]
819    fn det_balances_extreme_diagonals_independently_of_storage_order() {
820        let zero_tolerance = Tolerance::try_new(0.0).unwrap();
821        for diagonal in [
822            [TWO_NEG_800, TWO_NEG_800, TWO_POS_800, TWO_POS_800],
823            [TWO_POS_800, TWO_POS_800, TWO_NEG_800, TWO_NEG_800],
824        ] {
825            let mut rows = [[0.0; 4]; 4];
826            for (i, value) in diagonal.into_iter().enumerate() {
827                rows[i][i] = value;
828            }
829
830            let lu = Matrix::<4>::try_from_rows(rows)
831                .unwrap()
832                .lu(zero_tolerance)
833                .unwrap();
834            assert_eq!(lu.det(), Ok(1.0));
835        }
836    }
837
838    #[test]
839    fn matrix_det_fallback_inherits_balanced_extreme_accumulation() {
840        let zero_tolerance = Tolerance::try_new(0.0).unwrap();
841        for diagonal in [
842            [TWO_NEG_800, TWO_NEG_800, TWO_POS_800, TWO_POS_800, 1.0, 1.0],
843            [TWO_POS_800, TWO_POS_800, TWO_NEG_800, TWO_NEG_800, 1.0, 1.0],
844        ] {
845            let mut rows = [[0.0; 6]; 6];
846            for (i, value) in diagonal.into_iter().enumerate() {
847                rows[i][i] = value;
848            }
849
850            let matrix = Matrix::<6>::try_from_rows(rows).unwrap();
851            assert_eq!(matrix.det(), Ok(1.0));
852            assert_eq!(matrix.lu(zero_tolerance).unwrap().det(), Ok(1.0));
853        }
854    }
855
856    #[test]
857    fn det_rounds_final_tiny_magnitude_to_zero() {
858        let zero_tolerance = Tolerance::try_new(0.0).unwrap();
859        let positive =
860            Matrix::<2>::try_from_rows([[TWO_NEG_800, 0.0], [0.0, TWO_NEG_800]]).unwrap();
861        let positive_det = positive.lu(zero_tolerance).unwrap().det().unwrap();
862        assert_eq!(positive_det.to_bits(), 0.0f64.to_bits());
863
864        let negative =
865            Matrix::<2>::try_from_rows([[-TWO_NEG_800, 0.0], [0.0, TWO_NEG_800]]).unwrap();
866        let negative_det = negative.lu(zero_tolerance).unwrap().det().unwrap();
867        assert_eq!(negative_det.to_bits(), (-0.0f64).to_bits());
868    }
869
870    // -----------------------------------------------------------------------
871    // Const-evaluability tests.
872    //
873    // These prove that `Lu::det` and `Lu::solve` are truly `const fn` by
874    // forcing the compiler to evaluate them inside a `const` initializer.
875    // `Lu::factor` is not (yet) `const fn` because it relies on `<[T]>::swap`,
876    // which is not const-stable; we therefore construct `Lu<D>` directly.
877    // -----------------------------------------------------------------------
878
879    #[test]
880    fn lu_det_const_eval_d2() {
881        const DET: Result<f64, LaError> = {
882            // Triangular factors with diag [2.0, 3.0] and no row swaps.
883            let Ok(factors) = LuFactors::try_from_computation([[2.0, 0.0], [0.0, 3.0]]) else {
884                panic!("LU test factors must be finite");
885            };
886            let lu = Lu::<2> {
887                factors,
888                permutation: RowPermutation::identity(),
889            };
890            lu.det()
891        };
892        assert_eq!(DET, Ok(6.0));
893    }
894
895    #[test]
896    fn lu_det_const_eval_d3_row_swap() {
897        const DET: Result<f64, LaError> = {
898            // Identity factors with odd row-swap parity;
899            // the determinant magnitude is 1 but the sign flips.
900            let Ok(factors) = LuFactors::try_from_computation(Matrix::<3>::identity().into_rows())
901            else {
902                panic!("LU test factors must be usable");
903            };
904            let mut permutation = RowPermutation::identity();
905            permutation.swap(0, 1);
906            let lu = Lu::<3> {
907                factors,
908                permutation,
909            };
910            lu.det()
911        };
912        assert_eq!(DET, Ok(-1.0));
913    }
914
915    #[test]
916    fn lu_solve_const_eval_d2() {
917        // Identity LU ⇒ solve returns the permuted RHS untouched.
918        const X: Result<Vector<2>, LaError> = {
919            let Ok(factors) = LuFactors::try_from_computation(Matrix::<2>::identity().into_rows())
920            else {
921                panic!("LU test factors must be usable");
922            };
923            let lu = Lu::<2> {
924                factors,
925                permutation: RowPermutation::identity(),
926            };
927            let b = Vector::<2>::new([1.0, 2.0]);
928            lu.solve(b)
929        };
930        let x = X.unwrap().into_array();
931        assert!((x[0] - 1.0).abs() <= 1e-12);
932        assert!((x[1] - 2.0).abs() <= 1e-12);
933    }
934}