Skip to main content

echidna_optim/
linalg.rs

1use num_traits::Float;
2
3/// Result of LU factorization with partial pivoting.
4///
5/// Stores the combined L/U factors in a single matrix (L below diagonal,
6/// U on and above diagonal) plus the row permutation.
7pub struct LuFactors<F> {
8    /// Combined L/U matrix: L is below the diagonal (unit diagonal implicit),
9    /// U is on and above the diagonal.
10    lu: Vec<Vec<F>>,
11    /// Row permutation: `perm[i]` is the original row index for factored row `i`.
12    perm: Vec<usize>,
13    n: usize,
14}
15
16/// Factorize an `n x n` matrix via LU decomposition with partial pivoting.
17///
18/// Returns `None` if the matrix is singular or numerically unusable: an
19/// exact-zero pivot, a pivot below the `ε·n·‖A‖∞` relative threshold, or
20/// any non-finite pivot produced by a NaN / ±Inf input entry. The
21/// non-finite rejection matters because IEEE comparisons against NaN
22/// return `false`, so without it a NaN pivot would silently pass both
23/// the zero and tolerance checks and propagate through the stored LU
24/// factors.
25// Explicit indexing is clearer for pivoted LU: row/col indices drive pivot search and elimination
26#[allow(clippy::needless_range_loop)]
27pub fn lu_factor<F: Float>(a: &[Vec<F>]) -> Option<LuFactors<F>> {
28    let n = a.len();
29    debug_assert!(a.iter().all(|row| row.len() == n));
30
31    let mut lu: Vec<Vec<F>> = a.to_vec();
32    let mut perm: Vec<usize> = (0..n).collect();
33
34    // Use a relative singularity threshold scaled by the matrix infinity norm
35    // `‖A‖_∞ = max_i Σ_j |A[i][j]|`. Anchoring on the original-matrix scale
36    // is more robust than a running max-pivot-seen: an early small pivot (in
37    // a column that happens to be heavily cancelled) would otherwise lower
38    // the tolerance for every subsequent column, letting genuinely near-
39    // singular later pivots pass. ‖A‖_∞ is fixed at the start and reflects
40    // the true matrix magnitude.
41    let eps_mach = F::epsilon();
42    let n_f = F::from(n).unwrap();
43    let mut matrix_inf_norm = F::zero();
44    for row in a.iter() {
45        let row_sum = row.iter().fold(F::zero(), |acc, &x| acc + x.abs());
46        if row_sum > matrix_inf_norm {
47            matrix_inf_norm = row_sum;
48        }
49    }
50    let tol = eps_mach * n_f * matrix_inf_norm;
51
52    for col in 0..n {
53        // Find pivot
54        let mut max_val = lu[col][col].abs();
55        let mut max_row = col;
56        for row in (col + 1)..n {
57            let v = lu[row][col].abs();
58            if v > max_val {
59                max_val = v;
60                max_row = row;
61            }
62        }
63
64        // Reject non-finite pivots up front: NaN/Inf break both the zero
65        // check (NaN == 0 is false) and the tolerance comparison (NaN < tol
66        // is false), so without this they'd be accepted and produce
67        // NaN-tainted LU factors that downstream callers interpret as a
68        // successful solve.
69        if !max_val.is_finite() || max_val == F::zero() || max_val < tol {
70            return None; // Singular or non-finite
71        }
72
73        // Swap rows
74        if max_row != col {
75            lu.swap(col, max_row);
76            perm.swap(col, max_row);
77        }
78
79        let pivot = lu[col][col];
80
81        // Eliminate below, storing L factors in-place
82        for row in (col + 1)..n {
83            let factor = lu[row][col] / pivot;
84            lu[row][col] = factor; // Store L factor
85            for j in (col + 1)..n {
86                let val = lu[col][j];
87                lu[row][j] = lu[row][j] - factor * val;
88            }
89        }
90    }
91
92    Some(LuFactors { lu, perm, n })
93}
94
95/// Solve `A * x = b` using a pre-computed LU factorization.
96///
97/// This avoids re-factorizing when solving multiple right-hand sides
98/// against the same matrix.
99// Explicit indexing is clearer for forward/back substitution with permuted indices
100#[allow(clippy::needless_range_loop)]
101pub fn lu_back_solve<F: Float>(factors: &LuFactors<F>, b: &[F]) -> Vec<F> {
102    let n = factors.n;
103    debug_assert_eq!(b.len(), n);
104
105    // Apply permutation to b
106    let mut y = vec![F::zero(); n];
107    for i in 0..n {
108        y[i] = b[factors.perm[i]];
109    }
110
111    // Forward substitution (L * y' = permuted_b), L has unit diagonal
112    for i in 1..n {
113        for j in 0..i {
114            let l_ij = factors.lu[i][j];
115            let y_j = y[j];
116            y[i] = y[i] - l_ij * y_j;
117        }
118    }
119
120    // Back substitution (U * x = y')
121    let mut x = vec![F::zero(); n];
122    for i in (0..n).rev() {
123        let mut sum = y[i];
124        for j in (i + 1)..n {
125            sum = sum - factors.lu[i][j] * x[j];
126        }
127        x[i] = sum / factors.lu[i][i];
128    }
129
130    x
131}
132
133/// Solve `A * x = b` via LU factorization with partial pivoting.
134///
135/// `a` is an `n x n` matrix stored as `a[row][col]`.
136/// Returns `None` if [`lu_factor`] rejects `a` (singular, near-singular, or
137/// non-finite pivot). A non-finite entry in `b` will still propagate through
138/// the substitution and is not filtered here.
139pub fn lu_solve<F: Float>(a: &[Vec<F>], b: &[F]) -> Option<Vec<F>> {
140    let factors = lu_factor(a)?;
141    Some(lu_back_solve(&factors, b))
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn lu_solve_identity() {
150        let a = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
151        let b = vec![3.0, 7.0];
152        let x = lu_solve(&a, &b).unwrap();
153        assert!((x[0] - 3.0).abs() < 1e-12);
154        assert!((x[1] - 7.0).abs() < 1e-12);
155    }
156
157    #[test]
158    fn lu_solve_2x2() {
159        // [2 1] [x0]   [5]
160        // [1 3] [x1] = [7]
161        // Solution: x0 = 8/5, x1 = 9/5
162        let a = vec![vec![2.0, 1.0], vec![1.0, 3.0]];
163        let b = vec![5.0, 7.0];
164        let x = lu_solve(&a, &b).unwrap();
165        assert!((x[0] - 1.6).abs() < 1e-12);
166        assert!((x[1] - 1.8).abs() < 1e-12);
167    }
168
169    #[test]
170    fn lu_solve_singular() {
171        let a = vec![vec![1.0, 2.0], vec![2.0, 4.0]];
172        let b = vec![3.0, 6.0];
173        assert!(lu_solve(&a, &b).is_none());
174    }
175
176    #[test]
177    fn lu_solve_needs_pivoting() {
178        // First pivot is zero — requires row swap
179        let a = vec![vec![0.0, 1.0], vec![1.0, 0.0]];
180        let b = vec![3.0, 7.0];
181        let x = lu_solve(&a, &b).unwrap();
182        assert!((x[0] - 7.0).abs() < 1e-12);
183        assert!((x[1] - 3.0).abs() < 1e-12);
184    }
185
186    #[test]
187    fn lu_factor_then_back_solve_matches_lu_solve() {
188        let a = vec![vec![2.0, 1.0], vec![1.0, 3.0]];
189        let b1 = vec![5.0, 7.0];
190        let b2 = vec![1.0, 0.0];
191
192        // Factorize once
193        let factors = lu_factor(&a).unwrap();
194
195        // Solve two different RHS
196        let x1 = lu_back_solve(&factors, &b1);
197        let x2 = lu_back_solve(&factors, &b2);
198
199        // Compare with lu_solve
200        let x1_ref = lu_solve(&a, &b1).unwrap();
201        let x2_ref = lu_solve(&a, &b2).unwrap();
202
203        for i in 0..2 {
204            assert!((x1[i] - x1_ref[i]).abs() < 1e-12);
205            assert!((x2[i] - x2_ref[i]).abs() < 1e-12);
206        }
207    }
208
209    #[test]
210    fn lu_factor_then_back_solve_3x3() {
211        // [1 2 3] [x]   [14]
212        // [4 5 6] [y] = [32]
213        // [7 8 0] [z]   [23]
214        let a = vec![
215            vec![1.0, 2.0, 3.0],
216            vec![4.0, 5.0, 6.0],
217            vec![7.0, 8.0, 0.0],
218        ];
219        let b = vec![14.0, 32.0, 23.0];
220        let factors = lu_factor(&a).unwrap();
221        let x = lu_back_solve(&factors, &b);
222        let x_ref = lu_solve(&a, &b).unwrap();
223        for i in 0..3 {
224            assert!(
225                (x[i] - x_ref[i]).abs() < 1e-10,
226                "x[{}] = {}, expected {}",
227                i,
228                x[i],
229                x_ref[i]
230            );
231        }
232    }
233
234    #[test]
235    fn lu_factor_singular_returns_none() {
236        let a = vec![vec![1.0, 2.0], vec![2.0, 4.0]];
237        assert!(lu_factor(&a).is_none());
238    }
239
240    #[test]
241    fn lu_factor_nan_entry_returns_none() {
242        // A NaN anywhere in the matrix produces a NaN row sum → NaN
243        // `matrix_inf_norm` → NaN pivot candidates. Prior to the finite-pivot
244        // check, `NaN == 0` and `NaN < tol` both evaluate to `false`, so the
245        // NaN pivot was silently accepted and propagated through the stored
246        // LU factors, yielding a NaN-tainted "successful" solve. The check
247        // now rejects this up front.
248        let a = vec![vec![f64::NAN, 0.0], vec![0.0, 1.0]];
249        assert!(lu_factor(&a).is_none());
250    }
251
252    #[test]
253    fn lu_factor_inf_entry_returns_none() {
254        // `tol = ε·n·Inf = Inf`, so the genuine pivot comparison (`Inf < Inf`)
255        // is false and the `Inf == 0` check fails too. Without the finite-
256        // pivot guard the factorisation proceeds and produces `Inf/Inf = NaN`
257        // entries.
258        let a = vec![vec![f64::INFINITY, 0.0], vec![0.0, 1.0]];
259        assert!(lu_factor(&a).is_none());
260    }
261}