Skip to main content

faer_precond/
block_jacobi.rs

1//! Block-Jacobi preconditioner.
2//!
3//! Block-Jacobi generalises point-Jacobi from single diagonal entries to dense
4//! diagonal *blocks*. Reach for it when the unknowns fall into small,
5//! tightly-coupled groups — several fields per mesh node, coupled species, or
6//! any problem whose strong coupling is local and block-structured. Inverting
7//! each block exactly captures that coupling, which plain diagonal scaling
8//! cannot.
9//!
10//! `M = blkdiag(A_1, ..., A_p)`, where each `A_k` is the dense diagonal block
11//! `A[off_k..off_{k+1}, off_k..off_{k+1}]` of the source matrix. Each block is
12//! factored with partial-pivoted LU once at construction; subsequent applies
13//! are pure dense triangular solves and contain no heap allocation.
14
15use core::fmt::Debug;
16
17use dyn_stack::{MemBuffer, MemStack, StackReq};
18use faer::{
19    Conj, MatMut, MatRef, Par,
20    linalg::lu::partial_pivoting::{factor as plu_factor, solve as plu_solve},
21    matrix_free::{BiLinOp, BiPrecond, LinOp, Precond},
22    perm::PermRef,
23    prelude::ReborrowMut,
24};
25use faer_traits::ComplexField;
26use faer_traits::math_utils::{abs2, copy, zero};
27
28/// Error produced when constructing a [`BlockJacobiPrecond`].
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum BlockJacobiError {
31    /// The source matrix was not square.
32    NonSquareMatrix { nrows: usize, ncols: usize },
33    /// Less than two offsets were supplied (need at least one block).
34    EmptyBlocks,
35    /// The first offset must be `0`.
36    BlockOffsetsMustStartAtZero { first: usize },
37    /// The last offset must equal the matrix dimension.
38    BlockOffsetsMustEndAtDim { last: usize, dim: usize },
39    /// Offsets must be strictly increasing — blocks of size 0 are rejected.
40    BlockOffsetsNotStrictlyIncreasing {
41        index: usize,
42        prev: usize,
43        curr: usize,
44    },
45    /// One of the diagonal blocks was singular (numerically rank-deficient).
46    SingularBlock { block_index: usize },
47}
48
49impl core::fmt::Display for BlockJacobiError {
50    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
51        match self {
52            Self::NonSquareMatrix { nrows, ncols } => {
53                write!(f, "matrix must be square but is {nrows}x{ncols}")
54            }
55            Self::EmptyBlocks => f.write_str("at least one block is required"),
56            Self::BlockOffsetsMustStartAtZero { first } => {
57                write!(f, "block offsets must start at 0 but start at {first}")
58            }
59            Self::BlockOffsetsMustEndAtDim { last, dim } => {
60                write!(
61                    f,
62                    "block offsets must end at the matrix dimension {dim} but end at {last}"
63                )
64            }
65            Self::BlockOffsetsNotStrictlyIncreasing { index, prev, curr } => {
66                write!(
67                    f,
68                    "block offsets must be strictly increasing: offset[{index}]={curr} <= offset[{}]={prev}",
69                    index - 1
70                )
71            }
72            Self::SingularBlock { block_index } => {
73                write!(f, "diagonal block {block_index} is singular")
74            }
75        }
76    }
77}
78
79impl core::error::Error for BlockJacobiError {}
80
81/// Block-Jacobi preconditioner: `M^{-1} y = blkdiag(A_k^{-1}) y`.
82///
83/// The factorisation is computed once at construction. `apply`, `apply_in_place`,
84/// and the transpose/adjoint variants perform only dense triangular solves and
85/// permutation applications — they require no heap allocation and all scratch
86/// flows through the [`MemStack`] provided by faer's trait interface.
87///
88/// # Storage
89///
90/// All `p` LU factors are packed contiguously in column-major order into a
91/// single `Vec<T>`, and the partial-pivoting permutations are packed into two
92/// `Vec<usize>` of total length `n`. This keeps the working set cache-friendly
93/// during `apply`.
94#[derive(Debug, Clone)]
95pub struct BlockJacobiPrecond<T> {
96    n: usize,
97    block_offsets: Vec<usize>,
98    factor_offsets: Vec<usize>,
99    factors: Vec<T>,
100    perm_fwd: Vec<usize>,
101    perm_inv: Vec<usize>,
102    max_block_size: usize,
103}
104
105impl<T> BlockJacobiPrecond<T> {
106    /// Dimension `n` of the preconditioner (sum of block sizes).
107    #[inline]
108    pub fn dim(&self) -> usize {
109        self.n
110    }
111
112    /// `true` if the preconditioner has dimension zero.
113    #[inline]
114    pub fn is_empty(&self) -> bool {
115        self.n == 0
116    }
117
118    /// Number of diagonal blocks.
119    #[inline]
120    pub fn block_count(&self) -> usize {
121        self.block_offsets.len().saturating_sub(1)
122    }
123
124    /// Slice of length `block_count() + 1` giving the row/column offsets of each block.
125    #[inline]
126    pub fn block_offsets(&self) -> &[usize] {
127        &self.block_offsets
128    }
129
130    /// Size of the largest block — useful for sizing external scratch buffers.
131    #[inline]
132    pub fn max_block_size(&self) -> usize {
133        self.max_block_size
134    }
135}
136
137impl<T: ComplexField> BlockJacobiPrecond<T> {
138    /// Build a block-Jacobi preconditioner from `a` and a block partition.
139    ///
140    /// `block_offsets` must satisfy `block_offsets[0] == 0`, the values must
141    /// be strictly increasing, and `block_offsets[block_offsets.len() - 1]`
142    /// must equal `a.nrows()`.
143    ///
144    /// # Errors
145    ///
146    /// Returns [`BlockJacobiError::NonSquareMatrix`] if `a` is not square,
147    /// validation errors for ill-formed `block_offsets`, and
148    /// [`BlockJacobiError::SingularBlock`] if any diagonal block is rank-deficient.
149    pub fn try_new(a: MatRef<'_, T>, block_offsets: &[usize]) -> Result<Self, BlockJacobiError> {
150        if a.nrows() != a.ncols() {
151            return Err(BlockJacobiError::NonSquareMatrix {
152                nrows: a.nrows(),
153                ncols: a.ncols(),
154            });
155        }
156        let n = a.nrows();
157
158        if block_offsets.len() < 2 {
159            return Err(BlockJacobiError::EmptyBlocks);
160        }
161        if block_offsets[0] != 0 {
162            return Err(BlockJacobiError::BlockOffsetsMustStartAtZero {
163                first: block_offsets[0],
164            });
165        }
166        if *block_offsets.last().unwrap() != n {
167            return Err(BlockJacobiError::BlockOffsetsMustEndAtDim {
168                last: *block_offsets.last().unwrap(),
169                dim: n,
170            });
171        }
172        for window in block_offsets.windows(2).enumerate() {
173            let (i, w) = window;
174            if w[1] <= w[0] {
175                return Err(BlockJacobiError::BlockOffsetsNotStrictlyIncreasing {
176                    index: i + 1,
177                    prev: w[0],
178                    curr: w[1],
179                });
180            }
181        }
182
183        let nblocks = block_offsets.len() - 1;
184
185        // Compute factor offsets and total factor storage.
186        let mut factor_offsets = Vec::with_capacity(nblocks + 1);
187        factor_offsets.push(0);
188        let mut total_vals = 0usize;
189        let mut max_block_size = 0usize;
190        for i in 0..nblocks {
191            let size = block_offsets[i + 1] - block_offsets[i];
192            max_block_size = max_block_size.max(size);
193            total_vals += size * size;
194            factor_offsets.push(total_vals);
195        }
196
197        // Allocate packed buffers. All allocations live for the lifetime of the
198        // preconditioner; no allocation happens after construction.
199        let mut factors: Vec<T> = (0..total_vals).map(|_| zero::<T>()).collect();
200        let mut perm_fwd: Vec<usize> = vec![0; n];
201        let mut perm_inv: Vec<usize> = vec![0; n];
202
203        // One-shot factorisation scratch sized for the largest block.
204        let factor_scratch = plu_factor::lu_in_place_scratch::<usize, T>(
205            max_block_size,
206            max_block_size,
207            Par::Seq,
208            Default::default(),
209        );
210        let mut factor_buf = MemBuffer::new(factor_scratch);
211
212        for k in 0..nblocks {
213            let start = block_offsets[k];
214            let size = block_offsets[k + 1] - start;
215
216            // Copy block into the packed factor buffer (column-major).
217            let factor_range = factor_offsets[k]..factor_offsets[k + 1];
218            let block_slice = &mut factors[factor_range];
219            let mut block = MatMut::<T>::from_column_major_slice_mut(block_slice, size, size);
220            for j in 0..size {
221                for i in 0..size {
222                    *block.rb_mut().get_mut(i, j) = copy(a.get(start + i, start + j));
223                }
224            }
225
226            // Factor in place. The permutation is local to the block.
227            let perm_slice_fwd = &mut perm_fwd[start..start + size];
228            let perm_slice_inv = &mut perm_inv[start..start + size];
229            let _ = plu_factor::lu_in_place::<usize, T>(
230                block.rb_mut(),
231                perm_slice_fwd,
232                perm_slice_inv,
233                Par::Seq,
234                MemStack::new(&mut factor_buf),
235                Default::default(),
236            );
237
238            // Detect singular blocks: any zero on the diagonal of U.
239            let factor_view = MatRef::<T>::from_column_major_slice(
240                &factors[factor_offsets[k]..factor_offsets[k + 1]],
241                size,
242                size,
243            );
244            for i in 0..size {
245                if abs2(factor_view.get(i, i)) == zero::<T::Real>() {
246                    return Err(BlockJacobiError::SingularBlock { block_index: k });
247                }
248            }
249        }
250
251        Ok(Self {
252            n,
253            block_offsets: block_offsets.to_vec(),
254            factor_offsets,
255            factors,
256            perm_fwd,
257            perm_inv,
258            max_block_size,
259        })
260    }
261
262    /// Worst-case scratch required to apply this preconditioner.
263    #[inline]
264    fn solve_scratch(&self, rhs_ncols: usize, par: Par) -> StackReq {
265        plu_solve::solve_in_place_scratch::<usize, T>(self.max_block_size, rhs_ncols, par)
266    }
267
268    #[inline]
269    fn apply_blocks(
270        &self,
271        mut rhs: MatMut<'_, T>,
272        conj: Conj,
273        transpose: bool,
274        par: Par,
275        stack: &mut MemStack,
276    ) {
277        assert_eq!(
278            rhs.nrows(),
279            self.n,
280            "rhs row count must match preconditioner dimension"
281        );
282
283        let nblocks = self.block_count();
284        for k in 0..nblocks {
285            let start = self.block_offsets[k];
286            let size = self.block_offsets[k + 1] - start;
287
288            let factor_slice = &self.factors[self.factor_offsets[k]..self.factor_offsets[k + 1]];
289            let lu = MatRef::<T>::from_column_major_slice(factor_slice, size, size);
290
291            let perm = unsafe {
292                PermRef::<'_, usize>::new_unchecked(
293                    &self.perm_fwd[start..start + size],
294                    &self.perm_inv[start..start + size],
295                    size,
296                )
297            };
298
299            let rhs_block = rhs.rb_mut().subrows_mut(start, size);
300            if transpose {
301                plu_solve::solve_transpose_in_place_with_conj(
302                    lu, lu, perm, conj, rhs_block, par, stack,
303                );
304            } else {
305                plu_solve::solve_in_place_with_conj(lu, lu, perm, conj, rhs_block, par, stack);
306            }
307        }
308    }
309}
310
311impl<T> LinOp<T> for BlockJacobiPrecond<T>
312where
313    T: ComplexField + Debug + Sync,
314{
315    fn apply_scratch(&self, rhs_ncols: usize, par: Par) -> StackReq {
316        self.solve_scratch(rhs_ncols, par)
317    }
318
319    fn nrows(&self) -> usize {
320        self.n
321    }
322
323    fn ncols(&self) -> usize {
324        self.n
325    }
326
327    fn apply(&self, mut out: MatMut<'_, T>, rhs: MatRef<'_, T>, par: Par, stack: &mut MemStack) {
328        assert_eq!(
329            out.nrows(),
330            self.n,
331            "out row count must match preconditioner dimension"
332        );
333        assert_eq!(
334            rhs.nrows(),
335            self.n,
336            "rhs row count must match preconditioner dimension"
337        );
338        assert_eq!(
339            out.ncols(),
340            rhs.ncols(),
341            "out and rhs must have the same number of columns"
342        );
343        out.copy_from(rhs);
344        self.apply_blocks(out, Conj::No, false, par, stack);
345    }
346
347    fn conj_apply(
348        &self,
349        mut out: MatMut<'_, T>,
350        rhs: MatRef<'_, T>,
351        par: Par,
352        stack: &mut MemStack,
353    ) {
354        assert_eq!(
355            out.nrows(),
356            self.n,
357            "out row count must match preconditioner dimension"
358        );
359        assert_eq!(
360            rhs.nrows(),
361            self.n,
362            "rhs row count must match preconditioner dimension"
363        );
364        assert_eq!(
365            out.ncols(),
366            rhs.ncols(),
367            "out and rhs must have the same number of columns"
368        );
369        out.copy_from(rhs);
370        self.apply_blocks(out, Conj::Yes, false, par, stack);
371    }
372}
373
374impl<T> Precond<T> for BlockJacobiPrecond<T>
375where
376    T: ComplexField + Debug + Sync,
377{
378    fn apply_in_place_scratch(&self, rhs_ncols: usize, par: Par) -> StackReq {
379        self.solve_scratch(rhs_ncols, par)
380    }
381
382    fn apply_in_place(&self, rhs: MatMut<'_, T>, par: Par, stack: &mut MemStack) {
383        self.apply_blocks(rhs, Conj::No, false, par, stack);
384    }
385
386    fn conj_apply_in_place(&self, rhs: MatMut<'_, T>, par: Par, stack: &mut MemStack) {
387        self.apply_blocks(rhs, Conj::Yes, false, par, stack);
388    }
389}
390
391impl<T> BiLinOp<T> for BlockJacobiPrecond<T>
392where
393    T: ComplexField + Debug + Sync,
394{
395    fn transpose_apply_scratch(&self, rhs_ncols: usize, par: Par) -> StackReq {
396        self.solve_scratch(rhs_ncols, par)
397    }
398
399    fn transpose_apply(
400        &self,
401        mut out: MatMut<'_, T>,
402        rhs: MatRef<'_, T>,
403        par: Par,
404        stack: &mut MemStack,
405    ) {
406        assert_eq!(
407            out.nrows(),
408            self.n,
409            "out row count must match preconditioner dimension"
410        );
411        assert_eq!(
412            rhs.nrows(),
413            self.n,
414            "rhs row count must match preconditioner dimension"
415        );
416        assert_eq!(
417            out.ncols(),
418            rhs.ncols(),
419            "out and rhs must have the same number of columns"
420        );
421        out.copy_from(rhs);
422        self.apply_blocks(out, Conj::No, true, par, stack);
423    }
424
425    fn adjoint_apply(
426        &self,
427        mut out: MatMut<'_, T>,
428        rhs: MatRef<'_, T>,
429        par: Par,
430        stack: &mut MemStack,
431    ) {
432        assert_eq!(
433            out.nrows(),
434            self.n,
435            "out row count must match preconditioner dimension"
436        );
437        assert_eq!(
438            rhs.nrows(),
439            self.n,
440            "rhs row count must match preconditioner dimension"
441        );
442        assert_eq!(
443            out.ncols(),
444            rhs.ncols(),
445            "out and rhs must have the same number of columns"
446        );
447        out.copy_from(rhs);
448        self.apply_blocks(out, Conj::Yes, true, par, stack);
449    }
450}
451
452impl<T> BiPrecond<T> for BlockJacobiPrecond<T>
453where
454    T: ComplexField + Debug + Sync,
455{
456    fn transpose_apply_in_place_scratch(&self, rhs_ncols: usize, par: Par) -> StackReq {
457        self.solve_scratch(rhs_ncols, par)
458    }
459
460    fn transpose_apply_in_place(&self, rhs: MatMut<'_, T>, par: Par, stack: &mut MemStack) {
461        self.apply_blocks(rhs, Conj::No, true, par, stack);
462    }
463
464    fn adjoint_apply_in_place(&self, rhs: MatMut<'_, T>, par: Par, stack: &mut MemStack) {
465        self.apply_blocks(rhs, Conj::Yes, true, par, stack);
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use core::mem::MaybeUninit;
472
473    use super::*;
474    use faer::{
475        Mat, MatRef, mat,
476        matrix_free::{BiLinOp, LinOp, Precond},
477    };
478
479    fn with_stack(req: StackReq, f: impl FnOnce(&mut MemStack)) {
480        let nbytes = req.unaligned_bytes_required().max(1);
481        let mut buf = vec![MaybeUninit::<u8>::uninit(); nbytes].into_boxed_slice();
482        f(MemStack::new(&mut buf));
483    }
484
485    fn assert_close(lhs: MatRef<'_, f64>, rhs: MatRef<'_, f64>, tol: f64) {
486        assert_eq!(lhs.nrows(), rhs.nrows());
487        assert_eq!(lhs.ncols(), rhs.ncols());
488        for j in 0..lhs.ncols() {
489            for i in 0..lhs.nrows() {
490                let diff = (*lhs.get(i, j) - *rhs.get(i, j)).abs();
491                assert!(
492                    diff <= tol,
493                    "mismatch at ({i}, {j}): lhs={}, rhs={}, diff={diff}",
494                    *lhs.get(i, j),
495                    *rhs.get(i, j),
496                );
497            }
498        }
499    }
500
501    /// 5x5 block-diagonal with a 2x2 block and a 3x3 block (plus off-diagonal
502    /// noise that the preconditioner should ignore).
503    fn test_matrix() -> Mat<f64> {
504        mat![
505            [4.0, 1.0, 7.0, 9.0, 0.0],
506            [2.0, 3.0, 0.0, 0.0, 8.0],
507            [9.0, 5.0, 6.0, 1.0, 2.0],
508            [1.0, 1.0, 3.0, 5.0, 1.0],
509            [3.0, 0.0, 2.0, 1.0, 4.0],
510        ]
511    }
512
513    #[test]
514    fn builds_from_matrix() {
515        let a = test_matrix();
516        let pc = BlockJacobiPrecond::try_new(a.as_ref(), &[0, 2, 5]).unwrap();
517        assert_eq!(pc.dim(), 5);
518        assert_eq!(pc.block_count(), 2);
519        assert_eq!(pc.max_block_size(), 3);
520        assert_eq!(pc.block_offsets(), &[0, 2, 5]);
521    }
522
523    #[test]
524    fn rejects_non_square() {
525        let a = Mat::<f64>::from_fn(3, 4, |i, j| (i + j) as f64);
526        let err = BlockJacobiPrecond::try_new(a.as_ref(), &[0, 3]).unwrap_err();
527        assert_eq!(
528            err,
529            BlockJacobiError::NonSquareMatrix { nrows: 3, ncols: 4 }
530        );
531    }
532
533    #[test]
534    fn rejects_bad_offsets() {
535        let a = Mat::<f64>::identity(4, 4);
536        assert!(matches!(
537            BlockJacobiPrecond::try_new(a.as_ref(), &[]).unwrap_err(),
538            BlockJacobiError::EmptyBlocks
539        ));
540        assert!(matches!(
541            BlockJacobiPrecond::try_new(a.as_ref(), &[1, 4]).unwrap_err(),
542            BlockJacobiError::BlockOffsetsMustStartAtZero { first: 1 }
543        ));
544        assert!(matches!(
545            BlockJacobiPrecond::try_new(a.as_ref(), &[0, 3]).unwrap_err(),
546            BlockJacobiError::BlockOffsetsMustEndAtDim { last: 3, dim: 4 }
547        ));
548        assert!(matches!(
549            BlockJacobiPrecond::try_new(a.as_ref(), &[0, 2, 2, 4]).unwrap_err(),
550            BlockJacobiError::BlockOffsetsNotStrictlyIncreasing { .. }
551        ));
552    }
553
554    #[test]
555    fn rejects_singular_block() {
556        let mut a = Mat::<f64>::identity(4, 4);
557        // Make the 2x2 trailing block singular.
558        *a.as_mut().get_mut(2, 2) = 0.0;
559        *a.as_mut().get_mut(2, 3) = 1.0;
560        *a.as_mut().get_mut(3, 2) = 0.0;
561        *a.as_mut().get_mut(3, 3) = 0.0;
562        let err = BlockJacobiPrecond::try_new(a.as_ref(), &[0, 2, 4]).unwrap_err();
563        assert_eq!(err, BlockJacobiError::SingularBlock { block_index: 1 });
564    }
565
566    #[test]
567    fn apply_inverts_block_diagonal_part() {
568        // Pure block-diagonal A so that M = A and applying M^{-1} to A x recovers x.
569        let a = mat![
570            [4.0, 1.0, 0.0, 0.0, 0.0],
571            [2.0, 3.0, 0.0, 0.0, 0.0],
572            [0.0, 0.0, 6.0, 1.0, 2.0],
573            [0.0, 0.0, 3.0, 5.0, 1.0],
574            [0.0, 0.0, 2.0, 1.0, 4.0],
575        ];
576        let pc = BlockJacobiPrecond::try_new(a.as_ref(), &[0, 2, 5]).unwrap();
577
578        let x = mat![
579            [1.0, -2.0],
580            [2.0, 1.0],
581            [3.0, 0.5],
582            [-1.0, 2.0],
583            [0.5, -1.0_f64],
584        ];
585        let b = &a * &x;
586
587        let mut out = Mat::<f64>::zeros(5, 2);
588        with_stack(pc.apply_scratch(b.ncols(), Par::Seq), |stack| {
589            pc.apply(out.as_mut(), b.as_ref(), Par::Seq, stack);
590        });
591        assert_close(out.as_ref(), x.as_ref(), 1e-12);
592    }
593
594    #[test]
595    fn apply_in_place_matches_apply() {
596        let a = test_matrix();
597        let pc = BlockJacobiPrecond::try_new(a.as_ref(), &[0, 2, 5]).unwrap();
598
599        let rhs = mat![
600            [1.0, 4.0],
601            [2.0, 5.0],
602            [3.0, 6.0],
603            [4.0, 7.0],
604            [5.0, 8.0_f64],
605        ];
606
607        let mut out = Mat::<f64>::zeros(5, 2);
608        with_stack(pc.apply_scratch(rhs.ncols(), Par::Seq), |stack| {
609            pc.apply(out.as_mut(), rhs.as_ref(), Par::Seq, stack);
610        });
611
612        let mut inplace = rhs.to_owned();
613        with_stack(pc.apply_in_place_scratch(rhs.ncols(), Par::Seq), |stack| {
614            pc.apply_in_place(inplace.as_mut(), Par::Seq, stack);
615        });
616
617        assert_close(out.as_ref(), inplace.as_ref(), 1e-12);
618    }
619
620    #[test]
621    fn transpose_matches_block_transpose_solve() {
622        // For a block-diagonal A, M^{-T} y solves A^T y per block.
623        let a = mat![
624            [4.0, 1.0, 0.0, 0.0, 0.0],
625            [2.0, 3.0, 0.0, 0.0, 0.0],
626            [0.0, 0.0, 6.0, 1.0, 2.0],
627            [0.0, 0.0, 3.0, 5.0, 1.0],
628            [0.0, 0.0, 2.0, 1.0, 4.0_f64],
629        ];
630        let pc = BlockJacobiPrecond::try_new(a.as_ref(), &[0, 2, 5]).unwrap();
631
632        let x = mat![[1.0], [2.0], [3.0], [-1.0], [0.5_f64],];
633        let b = a.transpose() * &x;
634
635        let mut out = Mat::<f64>::zeros(5, 1);
636        with_stack(pc.transpose_apply_scratch(b.ncols(), Par::Seq), |stack| {
637            pc.transpose_apply(out.as_mut(), b.as_ref(), Par::Seq, stack);
638        });
639        assert_close(out.as_ref(), x.as_ref(), 1e-12);
640    }
641
642    #[test]
643    fn adjoint_equals_transpose_for_real() {
644        let a = test_matrix();
645        let pc = BlockJacobiPrecond::try_new(a.as_ref(), &[0, 2, 5]).unwrap();
646
647        let rhs = mat![[1.0], [-2.0], [3.0], [4.0], [-1.0_f64],];
648
649        let mut out_t = Mat::<f64>::zeros(5, 1);
650        with_stack(pc.transpose_apply_scratch(rhs.ncols(), Par::Seq), |stack| {
651            pc.transpose_apply(out_t.as_mut(), rhs.as_ref(), Par::Seq, stack);
652        });
653        let mut out_h = Mat::<f64>::zeros(5, 1);
654        with_stack(pc.transpose_apply_scratch(rhs.ncols(), Par::Seq), |stack| {
655            pc.adjoint_apply(out_h.as_mut(), rhs.as_ref(), Par::Seq, stack);
656        });
657        assert_close(out_t.as_ref(), out_h.as_ref(), 1e-12);
658    }
659
660    #[test]
661    fn single_block_matches_full_lu() {
662        // One big block == applying A^{-1} via partial-pivoted LU.
663        let a = mat![[4.0, 1.0, 2.0], [3.0, 5.0, 1.0], [1.0, 2.0, 6.0_f64],];
664        let pc = BlockJacobiPrecond::try_new(a.as_ref(), &[0, 3]).unwrap();
665
666        let x = mat![[1.0], [2.0], [3.0_f64],];
667        let b = &a * &x;
668
669        let mut out = Mat::<f64>::zeros(3, 1);
670        with_stack(pc.apply_scratch(b.ncols(), Par::Seq), |stack| {
671            pc.apply(out.as_mut(), b.as_ref(), Par::Seq, stack);
672        });
673        assert_close(out.as_ref(), x.as_ref(), 1e-12);
674    }
675}