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::{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
368        if D <= 4 {
369            // Tiny determinants compose better with factorization when range
370            // loss exits immediately instead of carrying an aggregate proof.
371            while i < D {
372                let step = range_checked_product(det, self.factors.diag(i));
373                if !step.range_preserved() {
374                    cold_path();
375                    return self.scaled_det();
376                }
377                det = step.product();
378                i += 1;
379            }
380            return Ok(det);
381        }
382
383        let mut range_preserved = true;
384        while i < D {
385            let factor = self.factors.diag(i);
386            let step = range_checked_product(det, factor);
387            det = step.product();
388            range_preserved &= step.range_preserved();
389            i += 1;
390        }
391        if range_preserved {
392            Ok(det)
393        } else {
394            cold_path();
395            self.scaled_det()
396        }
397    }
398
399    /// Recompute the determinant with normalized mantissa/exponent scaling.
400    #[cold]
401    const fn scaled_det(&self) -> Result<f64, LaError> {
402        let mut product = ScaledProduct::new(self.permutation.is_odd());
403        let mut i = 0;
404        while i < D {
405            product.multiply(self.factors.diag(i));
406            i += 1;
407        }
408
409        if let Some(det) = product.finish() {
410            Ok(det)
411        } else {
412            Err(LaError::non_finite_computation_step(
413                ArithmeticOperation::Determinant,
414                D.saturating_sub(1),
415            ))
416        }
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use core::array::from_fn;
423    use core::hint::black_box;
424
425    use approx::assert_abs_diff_eq;
426    use pastey::paste;
427
428    use super::*;
429    use crate::DEFAULT_SINGULAR_TOL;
430
431    const TWO_NEG_800: f64 = f64::from_bits(223_u64 << 52);
432
433    /// Check analytical solution bits after rotating equations to exercise pivoting.
434    fn assert_triangular_solution<const D: usize>(
435        rows: [[f64; D]; D],
436        rhs: [f64; D],
437        expected: [u64; D],
438        label: &str,
439    ) {
440        for rotation in [0, 1, D - 1] {
441            let mut rows = rows;
442            let mut rhs = rhs;
443            rows.rotate_left(rotation);
444            rhs.rotate_left(rotation);
445            let actual = Matrix::try_from_rows(rows)
446                .unwrap()
447                .lu(DEFAULT_SINGULAR_TOL)
448                .unwrap()
449                .solve(Vector::try_new(rhs).unwrap());
450            assert_eq!(
451                actual.map(|solution| solution.into_array().map(f64::to_bits)),
452                Ok(expected),
453                "{label}, D={D}, rotation={rotation}",
454            );
455        }
456    }
457
458    fn assert_solve_arithmetic_order<const D: usize>() {
459        // 1 - (1 - 2^-53)(1 + 2^-52) = -2^-53 + 2^-105 exactly.
460        // Separate multiplication/subtraction rounds the product to 1 instead.
461        // Scale the RHS by 2^-969, 1, and 2^970, covering subnormal solutions
462        // and large finite values. Expected bits come from the identity above;
463        // the subnormal result is -(2^-1022 - 2^-1074).
464        for (exponent, cancelled_bits) in [
465            (54_u64, 0x800f_ffff_ffff_ffff),
466            (1023, 0xbc9f_ffff_ffff_fffe),
467            (1993, 0xf93f_ffff_ffff_fffe),
468        ] {
469            let scale = f64::from_bits(exponent << 52);
470            let perturbed = f64::from_bits((exponent << 52) | 1);
471            for forward in [false, true] {
472                let (row, col) = if forward { (D - 1, 0) } else { (0, D - 1) };
473                let mut rows = Matrix::<D>::identity().into_rows();
474                rows[row][col] = f64::from_bits(0x3fef_ffff_ffff_ffff);
475                let mut rhs = [0.0; D];
476                rhs[row] = scale;
477                rhs[col] = perturbed;
478                let mut expected = [0; D];
479                expected[row] = cancelled_bits;
480                expected[col] = perturbed.to_bits();
481                assert_triangular_solution(
482                    rows,
483                    rhs,
484                    expected,
485                    &format!("fused cancellation, forward={forward}, exponent={exponent}"),
486                );
487            }
488        }
489
490        if D >= 3 {
491            // Ascending columns evaluate (1 - 2^53) + 2^53 = 1 exactly.
492            // Reversing them rounds 1 + 2^53 to 2^53 and yields 0 instead.
493            for forward in [false, true] {
494                let (row, first, second) = if forward {
495                    (D - 1, 0, 1)
496                } else {
497                    (0, 1, D - 1)
498                };
499                let mut rows = Matrix::<D>::identity().into_rows();
500                rows[row][first] = 0.5;
501                rows[row][second] = 0.5;
502                let mut rhs = [0.0; D];
503                rhs[row] = 1.0;
504                rhs[first] = f64::from_bits(1077_u64 << 52); // 2^54
505                rhs[second] = -rhs[first];
506                assert_triangular_solution(
507                    rows,
508                    rhs,
509                    rhs.map(f64::to_bits),
510                    &format!("ascending columns, forward={forward}"),
511                );
512            }
513        }
514    }
515
516    macro_rules! gen_solve_order_tests {
517        ($d:literal) => {
518            paste! {
519                #[test]
520                fn [<solve_preserves_row_arithmetic_ $d d>]() {
521                    assert_solve_arithmetic_order::<$d>();
522                }
523            }
524        };
525    }
526
527    gen_solve_order_tests!(2);
528    gen_solve_order_tests!(3);
529    gen_solve_order_tests!(4);
530    gen_solve_order_tests!(5);
531    gen_solve_order_tests!(8);
532    gen_solve_order_tests!(16);
533    gen_solve_order_tests!(32);
534    gen_solve_order_tests!(64);
535    const TWO_POS_800: f64 = f64::from_bits(1823_u64 << 52);
536
537    #[test]
538    fn row_permutation_keeps_mapping_and_parity_synchronized() {
539        let mut permutation = RowPermutation::<4>::identity();
540        assert_eq!(from_fn(|row| permutation.source_row(row)), [0, 1, 2, 3]);
541        assert!(!permutation.is_odd());
542
543        permutation.swap(0, 3);
544        assert_eq!(from_fn(|row| permutation.source_row(row)), [3, 1, 2, 0]);
545        assert!(permutation.is_odd());
546
547        permutation.swap(1, 2);
548        assert_eq!(from_fn(|row| permutation.source_row(row)), [3, 2, 1, 0]);
549        assert!(!permutation.is_odd());
550    }
551
552    macro_rules! gen_pivoting_solve_and_det_tests {
553        ($d:literal) => {
554            paste! {
555                #[test]
556                fn [<lu_solve_pivoting_ $d d>]() {
557                    // Public API path under test:
558                    // Matrix::lu (pub) -> Lu::solve (pub).
559
560                    // Permutation matrix that swaps the first two basis vectors.
561                    // This forces pivoting in column 0 for any D >= 2.
562                    let mut rows = [[0.0f64; $d]; $d];
563                    for i in 0..$d {
564                        rows[i][i] = 1.0;
565                    }
566                    rows.swap(0, 1);
567
568                    let a = Matrix::<$d>::try_from_rows(black_box(rows)).unwrap();
569                    let lu_fn: fn(Matrix<$d>, Tolerance) -> Result<Lu<$d>, LaError> =
570                        black_box(Matrix::<$d>::lu);
571                    let lu = lu_fn(a, DEFAULT_SINGULAR_TOL).unwrap();
572
573                    // Pick a simple RHS with unique entries, so the expected swap is obvious.
574                    let b_arr = {
575                        let mut arr = [0.0f64; $d];
576                        let mut val = 1.0f64;
577                        for dst in arr.iter_mut() {
578                            *dst = val;
579                            val += 1.0;
580                        }
581                        arr
582                    };
583                    let mut expected = b_arr;
584                    expected.swap(0, 1);
585                    let b = Vector::<$d>::new(black_box(b_arr));
586
587                    let solve_fn: fn(&Lu<$d>, Vector<$d>) -> Result<Vector<$d>, LaError> =
588                        black_box(Lu::<$d>::solve);
589                    let x = solve_fn(&lu, b).unwrap().into_array();
590
591                    for i in 0..$d {
592                        assert_abs_diff_eq!(x[i], expected[i], epsilon = 1e-12);
593                    }
594                }
595
596                #[test]
597                fn [<lu_det_pivoting_ $d d>]() {
598                    // Public API path under test:
599                    // Matrix::lu (pub) -> Lu::det (pub).
600
601                    // Permutation matrix that swaps the first two basis vectors.
602                    let mut rows = [[0.0f64; $d]; $d];
603                    for i in 0..$d {
604                        rows[i][i] = 1.0;
605                    }
606                    rows.swap(0, 1);
607
608                    let a = Matrix::<$d>::try_from_rows(black_box(rows)).unwrap();
609                    let lu_fn: fn(Matrix<$d>, Tolerance) -> Result<Lu<$d>, LaError> =
610                        black_box(Matrix::<$d>::lu);
611                    let lu = lu_fn(a, DEFAULT_SINGULAR_TOL).unwrap();
612
613                    // Row swap ⇒ determinant sign flip.
614                    let det_fn: fn(&Lu<$d>) -> Result<f64, LaError> =
615                        black_box(Lu::<$d>::det);
616                    assert_abs_diff_eq!(det_fn(&lu).unwrap(), -1.0, epsilon = 1e-12);
617                }
618            }
619        };
620    }
621
622    gen_pivoting_solve_and_det_tests!(2);
623    gen_pivoting_solve_and_det_tests!(3);
624    gen_pivoting_solve_and_det_tests!(4);
625    gen_pivoting_solve_and_det_tests!(5);
626
627    macro_rules! gen_tridiagonal_smoke_solve_and_det_tests {
628        ($d:literal $(, #[$stack_array_expectation:meta])?) => {
629            paste! {
630                #[test]
631                fn [<lu_solve_tridiagonal_smoke_ $d d>]() {
632                    // Public API path under test:
633                    // Matrix::lu (pub) -> Lu::solve (pub).
634
635                    // Classic SPD tridiagonal: 2 on diagonal, -1 on sub/super-diagonals.
636                    $(#[$stack_array_expectation])?
637                    let mut rows = [[0.0f64; $d]; $d];
638                    for i in 0..$d {
639                        rows[i][i] = 2.0;
640                        if i > 0 {
641                            rows[i][i - 1] = -1.0;
642                        }
643                        if i + 1 < $d {
644                            rows[i][i + 1] = -1.0;
645                        }
646                    }
647
648                    let a = Matrix::<$d>::try_from_rows(black_box(rows)).unwrap();
649                    let lu_fn: fn(Matrix<$d>, Tolerance) -> Result<Lu<$d>, LaError> =
650                        black_box(Matrix::<$d>::lu);
651                    let lu = lu_fn(a, DEFAULT_SINGULAR_TOL).unwrap();
652
653                    // Choose x = 1, so b = A x is simple: [1, 0, 0, ..., 0, 1].
654                    let mut b_arr = [0.0f64; $d];
655                    b_arr[0] = 1.0;
656                    b_arr[$d - 1] = 1.0;
657                    let b = Vector::<$d>::new(black_box(b_arr));
658
659                    let solve_fn: fn(&Lu<$d>, Vector<$d>) -> Result<Vector<$d>, LaError> =
660                        black_box(Lu::<$d>::solve);
661                    let x = solve_fn(&lu, b).unwrap().into_array();
662
663                    for &x_i in &x {
664                        assert_abs_diff_eq!(x_i, 1.0, epsilon = 1e-9);
665                    }
666                }
667
668                #[test]
669                fn [<lu_det_tridiagonal_smoke_ $d d>]() {
670                    // Public API path under test:
671                    // Matrix::lu (pub) -> Lu::det (pub).
672
673                    // Classic SPD tridiagonal: 2 on diagonal, -1 on sub/super-diagonals.
674                    // Determinant is known exactly: det = D + 1.
675                    $(#[$stack_array_expectation])?
676                    let mut rows = [[0.0f64; $d]; $d];
677                    for i in 0..$d {
678                        rows[i][i] = 2.0;
679                        if i > 0 {
680                            rows[i][i - 1] = -1.0;
681                        }
682                        if i + 1 < $d {
683                            rows[i][i + 1] = -1.0;
684                        }
685                    }
686
687                    let a = Matrix::<$d>::try_from_rows(black_box(rows)).unwrap();
688                    let lu_fn: fn(Matrix<$d>, Tolerance) -> Result<Lu<$d>, LaError> =
689                        black_box(Matrix::<$d>::lu);
690                    let lu = lu_fn(a, DEFAULT_SINGULAR_TOL).unwrap();
691
692                    let det_fn: fn(&Lu<$d>) -> Result<f64, LaError> =
693                        black_box(Lu::<$d>::det);
694                    assert_abs_diff_eq!(det_fn(&lu).unwrap(), f64::from($d) + 1.0, epsilon = 1e-8);
695                }
696            }
697        };
698    }
699
700    gen_tridiagonal_smoke_solve_and_det_tests!(16);
701    gen_tridiagonal_smoke_solve_and_det_tests!(32);
702    gen_tridiagonal_smoke_solve_and_det_tests!(
703        64,
704        #[expect(
705            clippy::large_stack_arrays,
706            reason = "the test deliberately exercises the crate's stack-allocated matrix storage"
707        )]
708    );
709
710    #[test]
711    fn solve_0x0_returns_empty_vector_and_unit_det() {
712        let a = Matrix::<0>::zero();
713        let lu = a.lu(DEFAULT_SINGULAR_TOL).unwrap();
714
715        assert_eq!(lu.det(), Ok(1.0));
716        assert!(
717            lu.solve(Vector::<0>::zero())
718                .unwrap()
719                .into_array()
720                .is_empty()
721        );
722    }
723
724    #[test]
725    fn solve_1x1() {
726        let a = Matrix::<1>::try_from_rows(black_box([[2.0]])).unwrap();
727        let lu = a.lu(DEFAULT_SINGULAR_TOL).unwrap();
728
729        let b = Vector::<1>::new(black_box([6.0]));
730        let solve_fn: fn(&Lu<1>, Vector<1>) -> Result<Vector<1>, LaError> =
731            black_box(Lu::<1>::solve);
732        let x = solve_fn(&lu, b).unwrap().into_array();
733        assert_abs_diff_eq!(x[0], 3.0, epsilon = 1e-12);
734
735        let det_fn: fn(&Lu<1>) -> Result<f64, LaError> = black_box(Lu::<1>::det);
736        assert_abs_diff_eq!(det_fn(&lu).unwrap(), 2.0, epsilon = 0.0);
737    }
738
739    #[test]
740    fn solve_2x2_basic() {
741        let a = Matrix::<2>::try_from_rows(black_box([[1.0, 2.0], [3.0, 4.0]])).unwrap();
742        let lu = a.lu(DEFAULT_SINGULAR_TOL).unwrap();
743        let b = Vector::<2>::new(black_box([5.0, 11.0]));
744
745        let solve_fn: fn(&Lu<2>, Vector<2>) -> Result<Vector<2>, LaError> =
746            black_box(Lu::<2>::solve);
747        let x = solve_fn(&lu, b).unwrap().into_array();
748
749        assert_abs_diff_eq!(x[0], 1.0, epsilon = 1e-12);
750        assert_abs_diff_eq!(x[1], 2.0, epsilon = 1e-12);
751    }
752
753    #[test]
754    fn det_2x2_basic() {
755        let a = Matrix::<2>::try_from_rows(black_box([[1.0, 2.0], [3.0, 4.0]])).unwrap();
756        let lu = a.lu(DEFAULT_SINGULAR_TOL).unwrap();
757
758        let det_fn: fn(&Lu<2>) -> Result<f64, LaError> = black_box(Lu::<2>::det);
759        assert_abs_diff_eq!(det_fn(&lu).unwrap(), -2.0, epsilon = 1e-12);
760    }
761
762    #[test]
763    fn det_ordinary_factors_matches_direct_product_bits() {
764        let diagonal = [1.5, -2.0, 0.25, 8.0];
765        let mut rows = [[0.0; 4]; 4];
766        let mut expected = 1.0;
767        for (i, factor) in diagonal.into_iter().enumerate() {
768            rows[i][i] = factor;
769            expected *= factor;
770        }
771
772        let lu = Matrix::<4>::try_from_rows(rows)
773            .unwrap()
774            .lu(DEFAULT_SINGULAR_TOL)
775            .unwrap();
776        assert_eq!(lu.det().unwrap().to_bits(), expected.to_bits());
777    }
778
779    #[test]
780    fn singular_detected() {
781        let a = Matrix::<2>::try_from_rows(black_box([[1.0, 2.0], [2.0, 4.0]])).unwrap();
782        let err = a.lu(DEFAULT_SINGULAR_TOL).unwrap_err();
783        assert_eq!(
784            err,
785            LaError::singular_numerical(1, FactorizationKind::Lu, 0.0, DEFAULT_SINGULAR_TOL.get())
786        );
787    }
788
789    #[test]
790    fn singular_due_to_tolerance_at_first_pivot() {
791        // Not exactly singular, but below DEFAULT_SINGULAR_TOL.
792        let a = Matrix::<2>::try_from_rows(black_box([[1e-13, 0.0], [0.0, 1.0]])).unwrap();
793        let err = a.lu(DEFAULT_SINGULAR_TOL).unwrap_err();
794        assert_eq!(
795            err,
796            LaError::singular_numerical(
797                0,
798                FactorizationKind::Lu,
799                1e-13,
800                DEFAULT_SINGULAR_TOL.get()
801            )
802        );
803    }
804
805    #[test]
806    fn non_finite_detected_in_trailing_update() {
807        let a = Matrix::<3>::try_from_rows([
808            [1.0, f64::MAX, 0.0],
809            [-1.0, f64::MAX, 0.0],
810            [0.0, 0.0, 1.0],
811        ])
812        .unwrap();
813
814        let err = a.lu(DEFAULT_SINGULAR_TOL).unwrap_err();
815        assert_eq!(
816            err,
817            LaError::non_finite_computation_matrix(ArithmeticOperation::LuFactorization, 1, 1)
818        );
819    }
820
821    #[test]
822    fn generated_non_finite_takes_precedence_over_later_singular_pivot() {
823        // The first update generates infinities, and the next generates NaN.
824        // NaN does not win a pivot comparison and must not be masked as singular.
825        let a = Matrix::<4>::try_from_rows([
826            [1.0, f64::MAX, 0.0, 0.0],
827            [1.0, f64::MAX, 0.0, 0.0],
828            [-1.0, f64::MAX, 0.0, 0.0],
829            [-1.0, f64::MAX, 0.0, 0.0],
830        ])
831        .unwrap();
832
833        let err = a.lu(DEFAULT_SINGULAR_TOL).unwrap_err();
834        assert_eq!(
835            err,
836            LaError::non_finite_computation_matrix(ArithmeticOperation::LuFactorization, 1, 1,)
837        );
838    }
839
840    #[test]
841    fn solve_non_finite_forward_substitution_overflow() {
842        // L has a -1 multiplier, and a large RHS makes forward substitution overflow.
843        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]])
844            .unwrap();
845        let lu = a.lu(DEFAULT_SINGULAR_TOL).unwrap();
846
847        let b = Vector::<3>::new([1.0e308, 1.0e308, 0.0]);
848        let err = lu.solve(b).unwrap_err();
849        assert_eq!(
850            err,
851            LaError::non_finite_computation_step(ArithmeticOperation::LuSolve, 1)
852        );
853    }
854
855    #[test]
856    fn solve_non_finite_forward_substitution_overflow_fused_branch_5d() {
857        // Exercises the D >= 5 fused pivot/forward-substitution branch with the
858        // same overflowing L multiplier as the D3 test.
859        let a = Matrix::<5>::try_from_rows([
860            [1.0, 0.0, 0.0, 0.0, 0.0],
861            [-1.0, 1.0, 0.0, 0.0, 0.0],
862            [0.0, 0.0, 1.0, 0.0, 0.0],
863            [0.0, 0.0, 0.0, 1.0, 0.0],
864            [0.0, 0.0, 0.0, 0.0, 1.0],
865        ])
866        .unwrap();
867        let lu = a.lu(DEFAULT_SINGULAR_TOL).unwrap();
868
869        let b = Vector::<5>::new([1.0e308, 1.0e308, 0.0, 0.0, 0.0]);
870        let err = lu.solve(b).unwrap_err();
871        assert_eq!(
872            err,
873            LaError::non_finite_computation_step(ArithmeticOperation::LuSolve, 1)
874        );
875    }
876
877    #[test]
878    fn solve_non_finite_back_substitution_overflow() {
879        // Make x[1] overflow during back substitution, then ensure it is detected on the next row.
880        let a = Matrix::<2>::try_from_rows([[1.0, 1.0], [0.0, 2.0e-12]]).unwrap();
881        let lu = a.lu(DEFAULT_SINGULAR_TOL).unwrap();
882
883        let b = Vector::<2>::new([0.0, 1.0e300]);
884        let err = lu.solve(b).unwrap_err();
885        assert_eq!(
886            err,
887            LaError::non_finite_computation_step(ArithmeticOperation::LuSolve, 1)
888        );
889    }
890
891    #[test]
892    fn solve_non_finite_back_substitution_sum_overflow() {
893        // Upper-triangular U with a very large off-diagonal in row 1 and a
894        // very large x[2] produced by the RHS.  The back-substitution
895        // accumulator `sum = (-row[j]).mul_add(x[j], sum)` overflows while
896        // reducing row 1, so the failure is detected via the `!sum.is_finite()`
897        // branch of the combined diag/sum check (distinct from the
898        // `q = sum / diag` overflow path covered above).
899        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]])
900            .unwrap();
901        let lu = a.lu(DEFAULT_SINGULAR_TOL).unwrap();
902
903        let b = Vector::<3>::new([0.0, 0.0, 1.0e200]);
904        let err = lu.solve(b).unwrap_err();
905        assert_eq!(
906            err,
907            LaError::non_finite_computation_step(ArithmeticOperation::LuSolve, 1)
908        );
909    }
910
911    #[test]
912    fn det_rejects_product_overflow() {
913        let a = Matrix::<5>::try_from_rows([
914            [1.0e100, 0.0, 0.0, 0.0, 0.0],
915            [0.0, 1.0e100, 0.0, 0.0, 0.0],
916            [0.0, 0.0, 1.0e100, 0.0, 0.0],
917            [0.0, 0.0, 0.0, 1.0e100, 0.0],
918            [0.0, 0.0, 0.0, 0.0, 1.0e100],
919        ])
920        .unwrap();
921        let lu = a.lu(DEFAULT_SINGULAR_TOL).unwrap();
922        assert_eq!(
923            lu.det(),
924            Err(LaError::non_finite_computation_step(
925                ArithmeticOperation::Determinant,
926                4
927            ))
928        );
929    }
930
931    #[test]
932    fn det_balances_extreme_diagonals_independently_of_storage_order() {
933        let zero_tolerance = Tolerance::try_new(0.0).unwrap();
934        for diagonal in [
935            [TWO_NEG_800, TWO_NEG_800, TWO_POS_800, TWO_POS_800],
936            [TWO_POS_800, TWO_POS_800, TWO_NEG_800, TWO_NEG_800],
937        ] {
938            let mut rows = [[0.0; 4]; 4];
939            for (i, value) in diagonal.into_iter().enumerate() {
940                rows[i][i] = value;
941            }
942
943            let lu = Matrix::<4>::try_from_rows(rows)
944                .unwrap()
945                .lu(zero_tolerance)
946                .unwrap();
947            assert_eq!(lu.det(), Ok(1.0));
948        }
949    }
950
951    #[test]
952    fn matrix_det_fallback_inherits_balanced_extreme_accumulation() {
953        let zero_tolerance = Tolerance::try_new(0.0).unwrap();
954        for diagonal in [
955            [TWO_NEG_800, TWO_NEG_800, TWO_POS_800, TWO_POS_800, 1.0, 1.0],
956            [TWO_POS_800, TWO_POS_800, TWO_NEG_800, TWO_NEG_800, 1.0, 1.0],
957        ] {
958            let mut rows = [[0.0; 6]; 6];
959            for (i, value) in diagonal.into_iter().enumerate() {
960                rows[i][i] = value;
961            }
962
963            let matrix = Matrix::<6>::try_from_rows(rows).unwrap();
964            assert_eq!(matrix.det(), Ok(1.0));
965            assert_eq!(matrix.lu(zero_tolerance).unwrap().det(), Ok(1.0));
966        }
967    }
968
969    #[test]
970    fn det_rounds_final_tiny_magnitude_to_zero() {
971        let zero_tolerance = Tolerance::try_new(0.0).unwrap();
972        let positive =
973            Matrix::<2>::try_from_rows([[TWO_NEG_800, 0.0], [0.0, TWO_NEG_800]]).unwrap();
974        let positive_det = positive.lu(zero_tolerance).unwrap().det().unwrap();
975        assert_eq!(positive_det.to_bits(), 0.0f64.to_bits());
976
977        let negative =
978            Matrix::<2>::try_from_rows([[-TWO_NEG_800, 0.0], [0.0, TWO_NEG_800]]).unwrap();
979        let negative_det = negative.lu(zero_tolerance).unwrap().det().unwrap();
980        assert_eq!(negative_det.to_bits(), (-0.0f64).to_bits());
981    }
982
983    // -----------------------------------------------------------------------
984    // Const-evaluability tests.
985    //
986    // These prove that `Lu::det` and `Lu::solve` are truly `const fn` by
987    // forcing the compiler to evaluate them inside a `const` initializer.
988    // `Lu::factor` is not (yet) `const fn` because it relies on `<[T]>::swap`,
989    // which is not const-stable; we therefore construct `Lu<D>` directly.
990    // -----------------------------------------------------------------------
991
992    #[test]
993    fn lu_det_const_eval_d2() {
994        const DET: Result<f64, LaError> = {
995            // Triangular factors with diag [2.0, 3.0] and no row swaps.
996            let Ok(factors) = LuFactors::try_from_computation([[2.0, 0.0], [0.0, 3.0]]) else {
997                panic!("LU test factors must be finite");
998            };
999            let lu = Lu::<2> {
1000                factors,
1001                permutation: RowPermutation::identity(),
1002            };
1003            lu.det()
1004        };
1005        assert_eq!(DET, Ok(6.0));
1006    }
1007
1008    #[test]
1009    fn lu_det_const_eval_d3_row_swap() {
1010        const DET: Result<f64, LaError> = {
1011            // Identity factors with odd row-swap parity;
1012            // the determinant magnitude is 1 but the sign flips.
1013            let Ok(factors) = LuFactors::try_from_computation(Matrix::<3>::identity().into_rows())
1014            else {
1015                panic!("LU test factors must be usable");
1016            };
1017            let mut permutation = RowPermutation::identity();
1018            permutation.swap(0, 1);
1019            let lu = Lu::<3> {
1020                factors,
1021                permutation,
1022            };
1023            lu.det()
1024        };
1025        assert_eq!(DET, Ok(-1.0));
1026    }
1027
1028    #[test]
1029    fn lu_solve_const_eval_d2() {
1030        // Identity LU ⇒ solve returns the permuted RHS untouched.
1031        const X: Result<Vector<2>, LaError> = {
1032            let Ok(factors) = LuFactors::try_from_computation(Matrix::<2>::identity().into_rows())
1033            else {
1034                panic!("LU test factors must be usable");
1035            };
1036            let lu = Lu::<2> {
1037                factors,
1038                permutation: RowPermutation::identity(),
1039            };
1040            let b = Vector::<2>::new([1.0, 2.0]);
1041            lu.solve(b)
1042        };
1043        let x = X.unwrap().into_array();
1044        assert!((x[0] - 1.0).abs() <= 1e-12);
1045        assert!((x[1] - 2.0).abs() <= 1e-12);
1046    }
1047}