Skip to main content

faer_precond/ilutp/
mod.rs

1//! Threshold ILU with partial pivoting (ILUTP).
2//!
3//! ILUTP is the general-purpose workhorse for nonsymmetric sparse systems
4//! solved with GMRES or BiCGSTAB. Where [`crate::Ilu0`] is locked to `A`'s
5//! sparsity pattern, ILUTP builds its factors *adaptively*: it keeps the most
6//! significant fill each row produces (governed by a drop tolerance and a fill
7//! budget) and pivots columns for numerical stability. That makes it far more
8//! broadly effective — including on matrices with small or absent diagonal
9//! entries, which it handles by pivoting a larger entry into place.
10//!
11//! It is the dual-threshold ILUT of Saad with column partial pivoting (the
12//! SPARSKIT `ilutp` algorithm). Tune it through [`IlutpParams`]:
13//!
14//! - `drop_tol` — relative threshold; an entry is dropped when its magnitude
15//!   falls below `drop_tol * ||row||`.
16//! - `fill` — how many entries to keep per row (see [`FillControl`]).
17//! - `pivot_tol` — in `[0, 1]`; how eagerly to pivot. `0` disables pivoting and
18//!   reduces the method to plain ILUT.
19//!
20//! # When to use it
21//!
22//! Reach for ILUTP on general nonsymmetric problems where [`crate::Ilu0`]
23//! stalls — convection-dominated flows, badly-scaled or indefinite operators,
24//! anything where the fixed-pattern factor is too weak. For symmetric
25//! positive-definite systems prefer [`crate::Ic0`], and for cheap, robust
26//! cases [`crate::Ilu0`] is lighter.
27//!
28//! # Value-dependent pattern
29//!
30//! ILUTP's fill pattern depends on the matrix *values*, not just its sparsity,
31//! so (unlike ILU(0)/IC(0)) there is no separate symbolic phase and
32//! [`Ilutp::refactorize`] reuses buffer capacity but is **not** allocation-free.
33//! The hot `apply` path stays allocation-free in the usual sense: its scratch
34//! flows through the caller's `MemStack` (a single length-`n` column, used for
35//! the permutation — analogous to [`crate::BlockJacobiPrecond`]).
36//!
37//! # Example
38//!
39//! ```
40//! use dyn_stack::{MemBuffer, MemStack};
41//! use faer::sparse::{SparseColMat, Triplet};
42//! use faer::matrix_free::{LinOp, Precond};
43//! use faer::{mat, Par};
44//! use faer_precond::{Ilutp, IlutpParams};
45//!
46//! // A small nonsymmetric tridiagonal: diag 4, sub -2, super -1.
47//! let mut triplets = Vec::new();
48//! for i in 0..6usize {
49//!     triplets.push(Triplet::new(i, i, 4.0_f64));
50//!     if i > 0 {
51//!         triplets.push(Triplet::new(i, i - 1, -2.0));
52//!         triplets.push(Triplet::new(i - 1, i, -1.0));
53//!     }
54//! }
55//! let a = SparseColMat::<usize, f64>::try_new_from_triplets(6, 6, &triplets).unwrap();
56//!
57//! let pc = Ilutp::try_new_with_params(a.as_ref(), IlutpParams::default()).unwrap();
58//!
59//! let mut b = mat![[1.0_f64], [0.0], [0.0], [0.0], [0.0], [0.0]];
60//! let mut buf = MemBuffer::new(pc.apply_in_place_scratch(1, Par::Seq));
61//! pc.apply_in_place(b.as_mut(), Par::Seq, MemStack::new(&mut buf));
62//! ```
63//!
64//! # Storage
65//!
66//! The factors are stored in column-compressed (CSC) form following faer's
67//! triangular-solve conventions — `L`'s unit diagonal first in each column,
68//! `U`'s diagonal last — plus the column permutation discovered during
69//! pivoting. Factorisation runs row-by-row (CSR) internally and transposes once
70//! into this layout.
71
72use core::fmt::Debug;
73
74use dyn_stack::{MemStack, StackReq};
75use faer::matrix_free::{BiLinOp, BiPrecond, LinOp, Precond};
76use faer::{Conj, MatMut, MatRef, Par};
77use faer_traits::{ComplexField, Index};
78
79pub mod apply;
80pub mod numeric;
81
82pub use numeric::Ilutp;
83
84/// How the per-row fill budget is chosen for each triangular part.
85#[derive(Debug, Clone, Copy, PartialEq)]
86pub enum FillControl {
87    /// Keep at most `p` off-diagonal entries per row in `L` and `p` in `U`
88    /// (SPARSKIT `lfil` semantics); the diagonal is always kept.
89    PerRow(usize),
90    /// Derive the per-row budget as `round(factor * nnz(A) / n)`, i.e. a
91    /// multiple of `A`'s average number of nonzeros per row.
92    Factor(f64),
93}
94
95/// Row norm used to scale the relative drop tolerance.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum RowNorm {
98    /// `||row||_1` — sum of magnitudes.
99    One,
100    /// `||row||_2` — Euclidean norm.
101    Two,
102}
103
104/// Tuning parameters for [`Ilutp`].
105///
106/// Construct with [`IlutpParams::default`] and override fields as needed:
107///
108/// ```
109/// use faer_precond::{FillControl, IlutpParams, RowNorm};
110/// let params = IlutpParams {
111///     drop_tol: 1e-2,
112///     fill: FillControl::PerRow(20),
113///     pivot_tol: 0.5,
114///     ..Default::default()
115/// };
116/// # let _ = (params.norm, RowNorm::Two);
117/// ```
118#[derive(Debug, Clone, Copy, PartialEq)]
119pub struct IlutpParams {
120    /// Relative drop tolerance. An entry is dropped when its magnitude is below
121    /// `drop_tol * ||row||`. Default `1e-3`.
122    pub drop_tol: f64,
123    /// Per-row fill budget. Default `FillControl::Factor(5.0)`.
124    pub fill: FillControl,
125    /// Column pivot tolerance in `[0, 1]`; `0` disables pivoting. Default `0.1`.
126    pub pivot_tol: f64,
127    /// Norm used to scale `drop_tol`. Default `RowNorm::Two`.
128    pub norm: RowNorm,
129}
130
131impl Default for IlutpParams {
132    fn default() -> Self {
133        Self {
134            drop_tol: 1e-3,
135            fill: FillControl::Factor(5.0),
136            pivot_tol: 0.1,
137            norm: RowNorm::Two,
138        }
139    }
140}
141
142impl IlutpParams {
143    pub(crate) fn validate(&self) -> Result<(), IlutpError> {
144        if !self.drop_tol.is_finite() || self.drop_tol < 0.0 {
145            return Err(IlutpError::InvalidDropTol);
146        }
147        if !self.pivot_tol.is_finite() || self.pivot_tol < 0.0 || self.pivot_tol > 1.0 {
148            return Err(IlutpError::InvalidPivotTol);
149        }
150        if let FillControl::Factor(f) = self.fill
151            && (!f.is_finite() || f <= 0.0)
152        {
153            return Err(IlutpError::InvalidFillControl);
154        }
155        Ok(())
156    }
157}
158
159/// Error returned by ILUTP construction or refactorisation.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum IlutpError {
162    /// The source matrix was not square.
163    NonSquareMatrix { nrows: usize, ncols: usize },
164    /// Row `row` reduced to a zero pivot even after pivoting — the matrix is
165    /// numerically singular for these parameters.
166    ZeroPivot { row: usize },
167    /// `drop_tol` was negative or NaN.
168    InvalidDropTol,
169    /// `pivot_tol` was outside `[0, 1]` or NaN.
170    InvalidPivotTol,
171    /// `FillControl::Factor` was non-positive or NaN.
172    InvalidFillControl,
173}
174
175impl core::fmt::Display for IlutpError {
176    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
177        match self {
178            Self::NonSquareMatrix { nrows, ncols } => {
179                write!(f, "matrix must be square but is {nrows}x{ncols}")
180            }
181            Self::ZeroPivot { row } => write!(f, "encountered a zero pivot at row {row}"),
182            Self::InvalidDropTol => f.write_str("drop_tol must be finite and non-negative"),
183            Self::InvalidPivotTol => f.write_str("pivot_tol must be finite and within [0, 1]"),
184            Self::InvalidFillControl => f.write_str("fill factor must be finite and positive"),
185        }
186    }
187}
188
189impl core::error::Error for IlutpError {}
190
191impl<I, T> LinOp<T> for Ilutp<I, T>
192where
193    I: Index,
194    T: ComplexField + Debug + Sync,
195{
196    fn apply_scratch(&self, _rhs_ncols: usize, _par: Par) -> StackReq {
197        StackReq::new::<T>(self.dim())
198    }
199
200    fn nrows(&self) -> usize {
201        self.dim()
202    }
203
204    fn ncols(&self) -> usize {
205        self.dim()
206    }
207
208    fn apply(&self, mut out: MatMut<'_, T>, rhs: MatRef<'_, T>, par: Par, stack: &mut MemStack) {
209        assert_eq!(
210            out.nrows(),
211            self.dim(),
212            "out row count must match dimension"
213        );
214        assert_eq!(
215            rhs.nrows(),
216            self.dim(),
217            "rhs row count must match dimension"
218        );
219        assert_eq!(out.ncols(), rhs.ncols(), "out and rhs ncols must match");
220        out.copy_from(rhs);
221        apply::solve_in_place(self, Conj::No, out, par, stack);
222    }
223
224    fn conj_apply(
225        &self,
226        mut out: MatMut<'_, T>,
227        rhs: MatRef<'_, T>,
228        par: Par,
229        stack: &mut MemStack,
230    ) {
231        assert_eq!(
232            out.nrows(),
233            self.dim(),
234            "out row count must match dimension"
235        );
236        assert_eq!(
237            rhs.nrows(),
238            self.dim(),
239            "rhs row count must match dimension"
240        );
241        assert_eq!(out.ncols(), rhs.ncols(), "out and rhs ncols must match");
242        out.copy_from(rhs);
243        apply::solve_in_place(self, Conj::Yes, out, par, stack);
244    }
245}
246
247impl<I, T> Precond<T> for Ilutp<I, T>
248where
249    I: Index,
250    T: ComplexField + Debug + Sync,
251{
252    fn apply_in_place_scratch(&self, _rhs_ncols: usize, _par: Par) -> StackReq {
253        StackReq::new::<T>(self.dim())
254    }
255
256    fn apply_in_place(&self, rhs: MatMut<'_, T>, par: Par, stack: &mut MemStack) {
257        apply::solve_in_place(self, Conj::No, rhs, par, stack);
258    }
259
260    fn conj_apply_in_place(&self, rhs: MatMut<'_, T>, par: Par, stack: &mut MemStack) {
261        apply::solve_in_place(self, Conj::Yes, rhs, par, stack);
262    }
263}
264
265impl<I, T> BiLinOp<T> for Ilutp<I, T>
266where
267    I: Index,
268    T: ComplexField + Debug + Sync,
269{
270    fn transpose_apply_scratch(&self, _rhs_ncols: usize, _par: Par) -> StackReq {
271        StackReq::new::<T>(self.dim())
272    }
273
274    fn transpose_apply(
275        &self,
276        mut out: MatMut<'_, T>,
277        rhs: MatRef<'_, T>,
278        par: Par,
279        stack: &mut MemStack,
280    ) {
281        assert_eq!(
282            out.nrows(),
283            self.dim(),
284            "out row count must match dimension"
285        );
286        assert_eq!(
287            rhs.nrows(),
288            self.dim(),
289            "rhs row count must match dimension"
290        );
291        assert_eq!(out.ncols(), rhs.ncols(), "out and rhs ncols must match");
292        out.copy_from(rhs);
293        apply::solve_transpose_in_place(self, Conj::No, out, par, stack);
294    }
295
296    fn adjoint_apply(
297        &self,
298        mut out: MatMut<'_, T>,
299        rhs: MatRef<'_, T>,
300        par: Par,
301        stack: &mut MemStack,
302    ) {
303        assert_eq!(
304            out.nrows(),
305            self.dim(),
306            "out row count must match dimension"
307        );
308        assert_eq!(
309            rhs.nrows(),
310            self.dim(),
311            "rhs row count must match dimension"
312        );
313        assert_eq!(out.ncols(), rhs.ncols(), "out and rhs ncols must match");
314        out.copy_from(rhs);
315        apply::solve_transpose_in_place(self, Conj::Yes, out, par, stack);
316    }
317}
318
319impl<I, T> BiPrecond<T> for Ilutp<I, T>
320where
321    I: Index,
322    T: ComplexField + Debug + Sync,
323{
324    fn transpose_apply_in_place_scratch(&self, _rhs_ncols: usize, _par: Par) -> StackReq {
325        StackReq::new::<T>(self.dim())
326    }
327
328    fn transpose_apply_in_place(&self, rhs: MatMut<'_, T>, par: Par, stack: &mut MemStack) {
329        apply::solve_transpose_in_place(self, Conj::No, rhs, par, stack);
330    }
331
332    fn adjoint_apply_in_place(&self, rhs: MatMut<'_, T>, par: Par, stack: &mut MemStack) {
333        apply::solve_transpose_in_place(self, Conj::Yes, rhs, par, stack);
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use core::mem::MaybeUninit;
341    use faer::sparse::{SparseColMat, SparseColMatRef, Triplet};
342    use faer::{Mat, MatRef, mat};
343
344    fn with_stack(req: StackReq, f: impl FnOnce(&mut MemStack)) {
345        let nbytes = req.unaligned_bytes_required().max(1);
346        let mut buf = vec![MaybeUninit::<u8>::uninit(); nbytes].into_boxed_slice();
347        f(MemStack::new(&mut buf));
348    }
349
350    fn assert_close(lhs: MatRef<'_, f64>, rhs: MatRef<'_, f64>, tol: f64) {
351        assert_eq!(lhs.nrows(), rhs.nrows());
352        assert_eq!(lhs.ncols(), rhs.ncols());
353        for j in 0..lhs.ncols() {
354            for i in 0..lhs.nrows() {
355                let diff = (*lhs.get(i, j) - *rhs.get(i, j)).abs();
356                assert!(
357                    diff <= tol,
358                    "mismatch at ({i}, {j}): lhs={}, rhs={}, diff={diff}",
359                    *lhs.get(i, j),
360                    *rhs.get(i, j),
361                );
362            }
363        }
364    }
365
366    fn to_dense(a: &SparseColMat<usize, f64>) -> Mat<f64> {
367        let n = a.nrows();
368        let mut out = Mat::<f64>::zeros(n, a.ncols());
369        let a_ref = a.as_ref();
370        for j in 0..a.ncols() {
371            let rows = a_ref.symbolic().row_idx_of_col_raw(j);
372            let vals = a_ref.val_of_col(j);
373            for (r, v) in rows.iter().zip(vals.iter()) {
374                *out.as_mut().get_mut(*r, j) = *v;
375            }
376        }
377        out
378    }
379
380    fn sparse_view_to_dense(a: SparseColMatRef<'_, usize, f64>) -> Mat<f64> {
381        let mut dense = Mat::<f64>::zeros(a.nrows(), a.ncols());
382        for j in 0..a.ncols() {
383            let rows = a.symbolic().row_idx_of_col_raw(j);
384            let vals = a.val_of_col(j);
385            for (r, v) in rows.iter().zip(vals.iter()) {
386                *dense.as_mut().get_mut(*r, j) = *v;
387            }
388        }
389        dense
390    }
391
392    /// Nonsymmetric tridiagonal: `diag` on the diagonal, `sub` below, `sup` above.
393    fn tridiagonal(n: usize, diag: f64, sub: f64, sup: f64) -> SparseColMat<usize, f64> {
394        let mut triplets = Vec::new();
395        for i in 0..n {
396            triplets.push(Triplet::new(i, i, diag));
397            if i > 0 {
398                triplets.push(Triplet::new(i, i - 1, sub));
399                triplets.push(Triplet::new(i - 1, i, sup));
400            }
401        }
402        SparseColMat::try_new_from_triplets(n, n, &triplets).unwrap()
403    }
404
405    /// 5-point Laplacian on a `grid x grid` mesh with an added asymmetric
406    /// first-derivative ("advection") term, giving a nonsymmetric M-matrix.
407    fn advection_diffusion_2d(grid: usize, beta: f64) -> SparseColMat<usize, f64> {
408        let n = grid * grid;
409        let mut triplets = Vec::new();
410        for gy in 0..grid {
411            for gx in 0..grid {
412                let idx = gy * grid + gx;
413                triplets.push(Triplet::new(idx, idx, 4.0));
414                if gx > 0 {
415                    triplets.push(Triplet::new(idx, idx - 1, -1.0 - beta));
416                }
417                if gx + 1 < grid {
418                    triplets.push(Triplet::new(idx, idx + 1, -1.0 + beta));
419                }
420                if gy > 0 {
421                    triplets.push(Triplet::new(idx, idx - grid, -1.0));
422                }
423                if gy + 1 < grid {
424                    triplets.push(Triplet::new(idx, idx + grid, -1.0));
425                }
426            }
427        }
428        SparseColMat::try_new_from_triplets(n, n, &triplets).unwrap()
429    }
430
431    fn apply_inplace(pc: &Ilutp<usize, f64>, rhs: &mut Mat<f64>) {
432        with_stack(pc.apply_in_place_scratch(rhs.ncols(), Par::Seq), |stack| {
433            pc.apply_in_place(rhs.as_mut(), Par::Seq, stack);
434        });
435    }
436
437    /// Parameters that keep everything: ILUTP reduces to a complete LU.
438    fn exact_params(n: usize, pivot_tol: f64) -> IlutpParams {
439        IlutpParams {
440            drop_tol: 0.0,
441            fill: FillControl::PerRow(n),
442            pivot_tol,
443            norm: RowNorm::Two,
444        }
445    }
446
447    #[test]
448    fn exact_keep_inverts_system() {
449        // drop_tol = 0, full fill, no pivoting => exact LU => M^{-1} A x == x.
450        let a = tridiagonal(8, 4.0, -2.0, -1.0);
451        let pc = Ilutp::try_new_with_params(a.as_ref(), exact_params(8, 0.0)).unwrap();
452        assert!(!pc.is_permuted());
453
454        let a_dense = to_dense(&a);
455        let x_true = mat![
456            [1.0],
457            [-2.0],
458            [3.0],
459            [-1.0],
460            [0.5],
461            [2.0],
462            [-3.0],
463            [1.5_f64]
464        ];
465        let mut rhs = (&a_dense * &x_true).to_owned();
466        apply_inplace(&pc, &mut rhs);
467        assert_close(rhs.as_ref(), x_true.as_ref(), 1e-10);
468    }
469
470    #[test]
471    fn exact_keep_with_pivoting_still_inverts() {
472        // Even when pivoting reorders columns, full-keep is an exact LU of A P,
473        // so M^{-1} A == I still holds.
474        let a = tridiagonal(8, 4.0, -2.0, -1.0);
475        let pc = Ilutp::try_new_with_params(a.as_ref(), exact_params(8, 0.5)).unwrap();
476
477        let a_dense = to_dense(&a);
478        let x_true = mat![
479            [1.0],
480            [-2.0],
481            [3.0],
482            [-1.0],
483            [0.5],
484            [2.0],
485            [-3.0],
486            [1.5_f64]
487        ];
488        let mut rhs = (&a_dense * &x_true).to_owned();
489        apply_inplace(&pc, &mut rhs);
490        assert_close(rhs.as_ref(), x_true.as_ref(), 1e-10);
491    }
492
493    #[test]
494    fn reconstruction_matches_permuted_a() {
495        // With no dropping and no pivoting, L * U == A exactly.
496        let a = advection_diffusion_2d(4, 0.3);
497        let n = a.nrows();
498        let pc = Ilutp::try_new_with_params(a.as_ref(), exact_params(n, 0.0)).unwrap();
499        assert!(!pc.is_permuted());
500
501        let l = sparse_view_to_dense(pc.l_view());
502        let u = sparse_view_to_dense(pc.u_view());
503        let lu = &l * &u;
504        let a_dense = to_dense(&a);
505        assert_close(lu.as_ref(), a_dense.as_ref(), 1e-9);
506    }
507
508    #[test]
509    fn reconstruction_matches_permuted_a_with_pivoting() {
510        // With pivoting, L * U == A * P where (A P)[:,k] = A[:, perm[k]].
511        let a = advection_diffusion_2d(4, 0.4);
512        let n = a.nrows();
513        let pc = Ilutp::try_new_with_params(a.as_ref(), exact_params(n, 0.5)).unwrap();
514
515        let l = sparse_view_to_dense(pc.l_view());
516        let u = sparse_view_to_dense(pc.u_view());
517        let lu = &l * &u;
518
519        let a_dense = to_dense(&a);
520        let perm = pc.perm();
521        let mut ap = Mat::<f64>::zeros(n, n);
522        for (k, &pk) in perm.iter().enumerate() {
523            for r in 0..n {
524                *ap.as_mut().get_mut(r, k) = *a_dense.as_ref().get(r, pk);
525            }
526        }
527        assert_close(lu.as_ref(), ap.as_ref(), 1e-9);
528    }
529
530    #[test]
531    fn pivot_tol_zero_is_pure_ilut() {
532        // A row with a tiny diagonal would normally pivot; pivot_tol = 0 forbids it.
533        let a = mat_to_sparse(&[&[1e-8, 1.0, 0.0], &[1.0, 1.0, 1.0], &[0.0, 1.0, 1.0]]);
534        let params = IlutpParams {
535            pivot_tol: 0.0,
536            ..exact_params(3, 0.0)
537        };
538        let pc = Ilutp::try_new_with_params(a.as_ref(), params).unwrap();
539        assert!(!pc.is_permuted());
540    }
541
542    #[test]
543    fn pivoting_triggers_on_tiny_diagonal() {
544        let a = mat_to_sparse(&[&[1e-8, 1.0, 0.0], &[1.0, 1.0, 1.0], &[0.0, 1.0, 1.0]]);
545        let pc = Ilutp::try_new_with_params(a.as_ref(), exact_params(3, 0.5)).unwrap();
546        assert!(pc.is_permuted(), "tiny diagonal should force a pivot");
547
548        // The pivot magnitude should be O(1), not O(1e-8).
549        let u = sparse_view_to_dense(pc.u_view());
550        assert!(
551            u.as_ref().get(0, 0).abs() > 1e-3,
552            "pivoting should avoid the tiny pivot, got {}",
553            u.as_ref().get(0, 0)
554        );
555    }
556
557    #[test]
558    fn reduces_residual_on_nonsymmetric_problem() {
559        let a = advection_diffusion_2d(8, 0.5);
560        let n = a.nrows();
561        let pc = Ilutp::try_new(a.as_ref()).unwrap();
562        let a_dense = to_dense(&a);
563
564        let b = Mat::<f64>::from_fn(n, 1, |i, _| (i % 7) as f64 - 3.0);
565        let mut x = b.clone();
566        apply_inplace(&pc, &mut x);
567
568        let residual = &a_dense * &x - &b;
569        let b_norm: f64 = b.as_ref().col(0).iter().map(|v| v * v).sum::<f64>().sqrt();
570        let r_norm: f64 = residual
571            .as_ref()
572            .col(0)
573            .iter()
574            .map(|v| v * v)
575            .sum::<f64>()
576            .sqrt();
577        assert!(
578            r_norm / b_norm < 0.5,
579            "ILUTP residual ratio {r_norm}/{b_norm} too large"
580        );
581    }
582
583    #[test]
584    fn transpose_apply_inverts_transposed_system() {
585        let a = tridiagonal(6, 4.0, -2.0, -1.0);
586        let pc = Ilutp::try_new_with_params(a.as_ref(), exact_params(6, 0.0)).unwrap();
587        let a_dense = to_dense(&a);
588
589        let x_true = mat![[1.0], [-2.0], [3.0], [-1.0], [0.5], [2.0_f64]];
590        let rhs = a_dense.transpose() * &x_true;
591
592        let mut out = rhs.clone();
593        with_stack(pc.transpose_apply_in_place_scratch(1, Par::Seq), |stack| {
594            pc.transpose_apply_in_place(out.as_mut(), Par::Seq, stack);
595        });
596        assert_close(out.as_ref(), x_true.as_ref(), 1e-10);
597    }
598
599    #[test]
600    fn out_of_place_matches_in_place() {
601        let a = advection_diffusion_2d(5, 0.3);
602        let n = a.nrows();
603        let pc = Ilutp::try_new(a.as_ref()).unwrap();
604
605        let rhs = Mat::<f64>::from_fn(n, 2, |i, j| ((i + 3 * j) % 11) as f64 - 4.0);
606
607        let mut out = Mat::<f64>::zeros(n, 2);
608        with_stack(pc.apply_scratch(2, Par::Seq), |stack| {
609            pc.apply(out.as_mut(), rhs.as_ref(), Par::Seq, stack);
610        });
611
612        let mut inplace = rhs.clone();
613        apply_inplace(&pc, &mut inplace);
614
615        assert_close(out.as_ref(), inplace.as_ref(), 1e-12);
616    }
617
618    #[test]
619    fn refactorize_matches_fresh_construction() {
620        // Deterministic pivoting (pivot_tol = 0) so both paths choose identically.
621        let a1 = tridiagonal(7, 4.0, -2.0, -1.0);
622        let a2 = tridiagonal(7, 5.0, -1.0, -2.0);
623        let params = IlutpParams {
624            pivot_tol: 0.0,
625            ..IlutpParams::default()
626        };
627
628        let fresh = Ilutp::try_new_with_params(a2.as_ref(), params).unwrap();
629
630        let mut reused = Ilutp::try_new_with_params(a1.as_ref(), params).unwrap();
631        reused.refactorize(a2.as_ref()).unwrap();
632
633        let lf = sparse_view_to_dense(fresh.l_view());
634        let lr = sparse_view_to_dense(reused.l_view());
635        let uf = sparse_view_to_dense(fresh.u_view());
636        let ur = sparse_view_to_dense(reused.u_view());
637        assert_close(lf.as_ref(), lr.as_ref(), 1e-14);
638        assert_close(uf.as_ref(), ur.as_ref(), 1e-14);
639    }
640
641    #[test]
642    fn rejects_non_square() {
643        let mut triplets = Vec::new();
644        for i in 0..3 {
645            triplets.push(Triplet::new(i, i, 1.0));
646        }
647        let a = SparseColMat::<usize, f64>::try_new_from_triplets(3, 4, &triplets).unwrap();
648        let err = Ilutp::try_new(a.as_ref()).unwrap_err();
649        assert_eq!(err, IlutpError::NonSquareMatrix { nrows: 3, ncols: 4 });
650    }
651
652    #[test]
653    fn rejects_zero_pivot_without_pivoting() {
654        // Structurally singular row 0 (zero diagonal, single off-diagonal) with
655        // pivoting disabled must fail with ZeroPivot.
656        let a = mat_to_sparse(&[&[0.0, 0.0, 0.0], &[1.0, 1.0, 0.0], &[0.0, 1.0, 1.0]]);
657        let params = IlutpParams {
658            pivot_tol: 0.0,
659            ..exact_params(3, 0.0)
660        };
661        let err = Ilutp::try_new_with_params(a.as_ref(), params).unwrap_err();
662        assert_eq!(err, IlutpError::ZeroPivot { row: 0 });
663    }
664
665    #[test]
666    fn rejects_invalid_params() {
667        let a = tridiagonal(3, 4.0, -1.0, -1.0);
668        let bad_drop = IlutpParams {
669            drop_tol: -1.0,
670            ..Default::default()
671        };
672        assert_eq!(
673            Ilutp::try_new_with_params(a.as_ref(), bad_drop).unwrap_err(),
674            IlutpError::InvalidDropTol
675        );
676        let bad_pivot = IlutpParams {
677            pivot_tol: 1.5,
678            ..Default::default()
679        };
680        assert_eq!(
681            Ilutp::try_new_with_params(a.as_ref(), bad_pivot).unwrap_err(),
682            IlutpError::InvalidPivotTol
683        );
684        let bad_fill = IlutpParams {
685            fill: FillControl::Factor(0.0),
686            ..Default::default()
687        };
688        assert_eq!(
689            Ilutp::try_new_with_params(a.as_ref(), bad_fill).unwrap_err(),
690            IlutpError::InvalidFillControl
691        );
692    }
693
694    /// Build a CSC matrix from a dense row-major description (test helper).
695    fn mat_to_sparse(rows: &[&[f64]]) -> SparseColMat<usize, f64> {
696        let n = rows.len();
697        let mut triplets = Vec::new();
698        for (i, row) in rows.iter().enumerate() {
699            for (j, &v) in row.iter().enumerate() {
700                if v != 0.0 {
701                    triplets.push(Triplet::new(i, j, v));
702                }
703            }
704        }
705        SparseColMat::try_new_from_triplets(n, n, &triplets).unwrap()
706    }
707}