Skip to main content

oxicuda_sparse/preconditioner/
ick.rs

1//! Incomplete Cholesky factorization with level-of-fill -- IC(k).
2//!
3//! For a symmetric positive-definite (SPD) matrix `A` this computes a sparse
4//! lower-triangular factor `L` such that `L Lᵀ ≈ A`, where `L` is allowed fill
5//! entries up to level `k` away from an original non-zero. IC(0) keeps only the
6//! original lower-triangle positions; larger `k` admits more fill, producing a
7//! more accurate (and, when `k ≥ n`, an exact) factorization.
8//!
9//! ## Symbolic phase
10//!
11//! The fill pattern is determined with the same level-of-fill recurrence as the
12//! crate's ILU(k) ([`iluk`](crate::preconditioner::iluk)): an original entry has
13//! level 0, and a fill entry `(i, j)` receives level
14//! `min over m ( level(i, m) + level(m, j) + 1 )`. Because `A` is symmetric the
15//! filled graph is symmetric, so the pattern of `L` is exactly the lower
16//! triangle of the symbolic ILU(k) pattern of `A`. With `k ≥ n` this reproduces
17//! the *complete* Cholesky fill and the factorization is exact.
18//!
19//! ## Numeric phase
20//!
21//! A left-looking Cholesky restricted to the symbolic pattern: for each column
22//! `j`, `L[j, j] = sqrt(A[j, j] − Σ_m L[j, m]²)` and
23//! `L[i, j] = (A[i, j] − Σ_m L[i, m] L[j, m]) / L[j, j]` for `i > j` in the
24//! pattern, where the sums run over columns `m < j` common to rows `i` and `j`.
25//! A non-positive pivot signals that the (modified) matrix is not SPD.
26
27use crate::error::{SparseError, SparseResult};
28use crate::host_csr::HostCsr;
29
30/// An IC(k) factorization: the lower-triangular Cholesky factor `L` (including
31/// its diagonal) stored in host CSR layout, together with the fill level used.
32#[derive(Debug, Clone)]
33pub struct IncompleteCholeskyK {
34    /// Lower-triangular factor `L` (with explicit diagonal), CSR layout.
35    l: HostCsr,
36    /// The level of fill `k` that produced this factor.
37    level: usize,
38}
39
40impl IncompleteCholeskyK {
41    /// Returns a reference to the lower-triangular factor `L`.
42    #[inline]
43    pub fn l_factor(&self) -> &HostCsr {
44        &self.l
45    }
46
47    /// Returns the level of fill `k`.
48    #[inline]
49    pub fn level(&self) -> usize {
50        self.level
51    }
52
53    /// Applies the preconditioner: solves `L Lᵀ z = r` and returns `z`.
54    ///
55    /// This performs a forward substitution `L y = r` followed by a backward
56    /// substitution `Lᵀ z = y`. For the *complete* factor this yields the exact
57    /// solution `z = A⁻¹ r`.
58    ///
59    /// # Panics
60    ///
61    /// Never panics; if `r` has the wrong length the surplus/missing entries
62    /// are treated as zero through the bounds of `L`.
63    pub fn apply(&self, r: &[f64]) -> Vec<f64> {
64        let n = self.l.nrows;
65        let mut y = vec![0.0f64; n];
66
67        // Forward solve L y = r (L lower-triangular with explicit diagonal).
68        for i in 0..n {
69            let start = self.l.row_ptr[i];
70            let end = self.l.row_ptr[i + 1];
71            let mut sum = if i < r.len() { r[i] } else { 0.0 };
72            let mut diag = 1.0;
73            for kk in start..end {
74                let j = self.l.col_indices[kk];
75                if j < i {
76                    sum -= self.l.values[kk] * y[j];
77                } else if j == i {
78                    diag = self.l.values[kk];
79                }
80            }
81            y[i] = sum / diag;
82        }
83
84        // Backward solve Lᵀ z = y. Iterate rows from the bottom; row i of L
85        // contributes its off-diagonal entries to the already-solved unknowns.
86        let mut z = vec![0.0f64; n];
87        for i in (0..n).rev() {
88            let start = self.l.row_ptr[i];
89            let end = self.l.row_ptr[i + 1];
90            let mut diag = 1.0;
91            for kk in start..end {
92                if self.l.col_indices[kk] == i {
93                    diag = self.l.values[kk];
94                }
95            }
96            z[i] = y[i] / diag;
97            // Subtract this unknown's contribution from earlier rows.
98            for kk in start..end {
99                let j = self.l.col_indices[kk];
100                if j < i {
101                    y[j] -= self.l.values[kk] * z[i];
102                }
103            }
104        }
105        z
106    }
107}
108
109/// Computes the IC(k) factorization of an SPD host CSR matrix.
110///
111/// # Arguments
112///
113/// * `a` -- a square SPD matrix in host CSR layout. Both triangles may be
114///   stored; only the symmetric structure is used for the pattern and the
115///   stored entries are read for the numeric values.
116/// * `k` -- the level of fill (`0` = IC(0); large `k` gives complete Cholesky).
117///
118/// # Errors
119///
120/// Returns [`SparseError::DimensionMismatch`] if `a` is not square,
121/// [`SparseError::InvalidArgument`] if `a` is empty, or
122/// [`SparseError::SingularMatrix`] if a non-positive pivot is encountered
123/// (i.e. the matrix is not SPD on the retained pattern).
124pub fn ic_k(a: &HostCsr, k: usize) -> SparseResult<IncompleteCholeskyK> {
125    if a.nrows != a.ncols {
126        return Err(SparseError::DimensionMismatch(format!(
127            "IC(k) requires a square matrix, got {}x{}",
128            a.nrows, a.ncols
129        )));
130    }
131    let n = a.nrows;
132    if n == 0 {
133        return Err(SparseError::InvalidArgument(
134            "cannot factor an empty matrix".to_string(),
135        ));
136    }
137
138    // Symbolic phase: full symmetric level-of-fill pattern, then keep lower tri.
139    let lower_pattern = ic_k_symbolic(a, k);
140
141    // Numeric phase: left-looking Cholesky on the lower-triangular pattern.
142    let values = ic_k_numeric(a, &lower_pattern, n)?;
143
144    let mut row_ptr = vec![0usize; n + 1];
145    let mut col_indices = Vec::new();
146    let mut out_values = Vec::new();
147    for i in 0..n {
148        for &(col, val) in &lower_pattern[i] {
149            col_indices.push(col);
150            out_values.push(values[&(i, col)]);
151            let _ = val;
152        }
153        row_ptr[i + 1] = col_indices.len();
154    }
155
156    let l = HostCsr::new(n, n, row_ptr, col_indices, out_values)?;
157    Ok(IncompleteCholeskyK { l, level: k })
158}
159
160/// Sparse-row entry carrying a level-of-fill annotation.
161#[derive(Clone, Copy)]
162struct LevEntry {
163    col: usize,
164    level: usize,
165}
166
167/// Computes the lower-triangular fill pattern of `L` with level-of-fill `k`.
168///
169/// Returns, for each row `i`, a sorted list of `(col, level)` pairs with
170/// `col ≤ i` (the explicit diagonal is always present). The recurrence mirrors
171/// [`iluk`](crate::preconditioner::iluk) but is applied to the symmetric graph,
172/// so that with `k ≥ n` the result is the exact Cholesky fill.
173fn ic_k_symbolic(a: &HostCsr, k: usize) -> Vec<Vec<(usize, usize)>> {
174    let n = a.nrows;
175
176    // Build the symmetric adjacency with level 0 for every structural entry of
177    // A and its transpose (so the pattern is symmetric even if A stored only
178    // one triangle). The diagonal is forced present.
179    let mut rows: Vec<Vec<LevEntry>> = Vec::with_capacity(n);
180    {
181        let mut sets: Vec<std::collections::BTreeMap<usize, usize>> =
182            vec![std::collections::BTreeMap::new(); n];
183        for i in 0..n {
184            let start = a.row_ptr[i];
185            let end = a.row_ptr[i + 1];
186            sets[i].insert(i, 0);
187            for kk in start..end {
188                let j = a.col_indices[kk];
189                sets[i].insert(j, 0);
190                sets[j].insert(i, 0);
191            }
192        }
193        for set in sets {
194            rows.push(
195                set.into_iter()
196                    .map(|(col, level)| LevEntry { col, level })
197                    .collect(),
198            );
199        }
200    }
201
202    // Symmetric level-of-fill elimination, processing rows in order. For row i,
203    // for each lower neighbour m < i with level lev_im, pull in the upper part
204    // of row m (cols j with m < j, i.e. the transpose entries) to generate
205    // fill (i, j).
206    for i in 0..n {
207        let mut idx = 0;
208        loop {
209            if idx >= rows[i].len() {
210                break;
211            }
212            let m = rows[i][idx].col;
213            if m >= i {
214                break;
215            }
216            let lev_im = rows[i][idx].level;
217
218            // Entries of row m with column j > m (the "upper" structure of the
219            // factor at row m). By symmetry these are exactly the lower
220            // neighbours of those j through m.
221            let upper_m: Vec<(usize, usize)> = rows[m]
222                .iter()
223                .filter(|e| e.col > m)
224                .map(|e| (e.col, e.level))
225                .collect();
226
227            for (j, lev_mj) in upper_m {
228                if j >= i {
229                    // Only fill the lower triangle of row i (cols < i) plus the
230                    // diagonal; columns >= i are handled when their own row is
231                    // processed via symmetry.
232                    if j != i {
233                        continue;
234                    }
235                }
236                let new_level = lev_im + lev_mj + 1;
237                if new_level > k {
238                    continue;
239                }
240                match rows[i].iter().position(|e| e.col == j) {
241                    Some(pos) => {
242                        if new_level < rows[i][pos].level {
243                            rows[i][pos].level = new_level;
244                        }
245                    }
246                    None => {
247                        let insert_pos = rows[i]
248                            .iter()
249                            .position(|e| e.col > j)
250                            .unwrap_or(rows[i].len());
251                        rows[i].insert(
252                            insert_pos,
253                            LevEntry {
254                                col: j,
255                                level: new_level,
256                            },
257                        );
258                    }
259                }
260            }
261            idx += 1;
262        }
263    }
264
265    // Keep only the lower triangle (cols <= i) for L.
266    rows.into_iter()
267        .enumerate()
268        .map(|(i, row)| {
269            row.into_iter()
270                .filter(|e| e.col <= i)
271                .map(|e| (e.col, e.level))
272                .collect()
273        })
274        .collect()
275}
276
277/// Left-looking numeric Cholesky on the given lower-triangular pattern.
278///
279/// Returns a map from `(row, col)` to the computed value of `L[row, col]`.
280fn ic_k_numeric(
281    a: &HostCsr,
282    pattern: &[Vec<(usize, usize)>],
283    n: usize,
284) -> SparseResult<std::collections::HashMap<(usize, usize), f64>> {
285    // Dense column views of L are unnecessary; we store the computed entries in
286    // a map and, for each row, a sorted column list to intersect rows i and j.
287    let mut l: std::collections::HashMap<(usize, usize), f64> = std::collections::HashMap::new();
288
289    // Pre-extract sorted column lists per row for fast intersection.
290    let row_cols: Vec<Vec<usize>> = pattern
291        .iter()
292        .map(|row| row.iter().map(|&(c, _)| c).collect())
293        .collect();
294
295    for i in 0..n {
296        for &(j, _lev) in &pattern[i] {
297            if j > i {
298                continue;
299            }
300            // Dot product Σ_{m < j} L[i,m] L[j,m] over common columns m.
301            let mut sum = a.get(i, j).unwrap_or(0.0);
302            let ci = &row_cols[i];
303            let cj = &row_cols[j];
304            let (mut pi, mut pj) = (0usize, 0usize);
305            while pi < ci.len() && pj < cj.len() {
306                let mi = ci[pi];
307                let mj = cj[pj];
308                if mi >= j || mj >= j {
309                    break;
310                }
311                match mi.cmp(&mj) {
312                    std::cmp::Ordering::Less => pi += 1,
313                    std::cmp::Ordering::Greater => pj += 1,
314                    std::cmp::Ordering::Equal => {
315                        let lim = l.get(&(i, mi)).copied().unwrap_or(0.0);
316                        let ljm = l.get(&(j, mj)).copied().unwrap_or(0.0);
317                        sum -= lim * ljm;
318                        pi += 1;
319                        pj += 1;
320                    }
321                }
322            }
323
324            if i == j {
325                if sum <= 0.0 {
326                    return Err(SparseError::SingularMatrix);
327                }
328                l.insert((i, j), sum.sqrt());
329            } else {
330                let ljj = l.get(&(j, j)).copied().unwrap_or(0.0);
331                if ljj == 0.0 {
332                    return Err(SparseError::SingularMatrix);
333                }
334                l.insert((i, j), sum / ljj);
335            }
336        }
337    }
338
339    Ok(l)
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use crate::host_csr::{HostCsr, laplacian_1d, laplacian_2d};
346
347    /// A small dense-ish SPD matrix (Gram matrix of a random-ish basis) used to
348    /// exercise complete-fill exactness.
349    fn spd_dense_like(n: usize) -> HostCsr {
350        // Build B (n x n) deterministically, then A = Bᵀ B + n*I (SPD, dense).
351        let mut b = vec![0.0f64; n * n];
352        let mut state: u64 = 12345;
353        for v in b.iter_mut() {
354            state = state.wrapping_mul(6364136223846793005).wrapping_add(1);
355            *v = ((state >> 33) as f64 / (1u64 << 31) as f64) - 1.0;
356        }
357        let mut dense = vec![0.0f64; n * n];
358        for i in 0..n {
359            for j in 0..n {
360                let mut acc = 0.0;
361                for m in 0..n {
362                    acc += b[m * n + i] * b[m * n + j];
363                }
364                if i == j {
365                    acc += n as f64;
366                }
367                dense[i * n + j] = acc;
368            }
369        }
370        dense_to_csr(&dense, n)
371    }
372
373    fn dense_to_csr(dense: &[f64], n: usize) -> HostCsr {
374        let mut row_ptr = vec![0usize; n + 1];
375        let mut col_indices = Vec::new();
376        let mut values = Vec::new();
377        for i in 0..n {
378            for j in 0..n {
379                let v = dense[i * n + j];
380                if v != 0.0 {
381                    col_indices.push(j);
382                    values.push(v);
383                }
384            }
385            row_ptr[i + 1] = col_indices.len();
386        }
387        HostCsr::new(n, n, row_ptr, col_indices, values).expect("valid")
388    }
389
390    /// Reconstruct (L Lᵀ)[i,j] for a host-CSR lower factor.
391    fn llt(l: &HostCsr, i: usize, j: usize) -> f64 {
392        // Row i and row j of L; dot over common columns.
393        let ci_s = l.row_ptr[i];
394        let ci_e = l.row_ptr[i + 1];
395        let cj_s = l.row_ptr[j];
396        let cj_e = l.row_ptr[j + 1];
397        let mut acc = 0.0;
398        let (mut pi, mut pj) = (ci_s, cj_s);
399        while pi < ci_e && pj < cj_e {
400            let a = l.col_indices[pi];
401            let b = l.col_indices[pj];
402            match a.cmp(&b) {
403                std::cmp::Ordering::Less => pi += 1,
404                std::cmp::Ordering::Greater => pj += 1,
405                std::cmp::Ordering::Equal => {
406                    acc += l.values[pi] * l.values[pj];
407                    pi += 1;
408                    pj += 1;
409                }
410            }
411        }
412        acc
413    }
414
415    #[test]
416    fn llt_matches_a_on_pattern_ic0() {
417        // For IC(0) on the 1D Laplacian, (LLᵀ)[i,j] == A[i,j] for (i,j) in the
418        // pattern -- the defining property of incomplete Cholesky.
419        let a = laplacian_1d(8);
420        let fac = ic_k(&a, 0).expect("ic0");
421        let l = fac.l_factor();
422        for i in 0..l.nrows {
423            let s = l.row_ptr[i];
424            let e = l.row_ptr[i + 1];
425            for kk in s..e {
426                let j = l.col_indices[kk];
427                let recon = llt(l, i, j);
428                let aij = a.get(i, j).unwrap_or(0.0);
429                assert!(
430                    (recon - aij).abs() < 1e-12,
431                    "IC(0) pattern mismatch at ({i},{j}): {recon} vs {aij}"
432                );
433            }
434        }
435    }
436
437    #[test]
438    fn fill_pattern_monotone() {
439        // IC(0) ⊆ IC(1) ⊆ IC(2) on a 2D Laplacian (non-trivial fill).
440        let a = laplacian_2d(5, 5);
441        let p0 = ic_k_symbolic(&a, 0);
442        let p1 = ic_k_symbolic(&a, 1);
443        let p2 = ic_k_symbolic(&a, 2);
444        for i in 0..a.nrows {
445            let s0: std::collections::HashSet<usize> = p0[i].iter().map(|&(c, _)| c).collect();
446            let s1: std::collections::HashSet<usize> = p1[i].iter().map(|&(c, _)| c).collect();
447            let s2: std::collections::HashSet<usize> = p2[i].iter().map(|&(c, _)| c).collect();
448            assert!(s0.is_subset(&s1), "IC(0) not subset of IC(1) at row {i}");
449            assert!(s1.is_subset(&s2), "IC(1) not subset of IC(2) at row {i}");
450        }
451    }
452
453    #[test]
454    fn complete_fill_reconstructs_exactly() {
455        // Large k => complete Cholesky => L Lᵀ == A exactly (dense compare).
456        let n = 10;
457        let a = spd_dense_like(n);
458        let fac = ic_k(&a, n + 5).expect("complete cholesky");
459        let l = fac.l_factor();
460        for i in 0..n {
461            for j in 0..n {
462                let recon = llt(l, i, j);
463                let aij = a.get(i, j).unwrap_or(0.0);
464                assert!(
465                    (recon - aij).abs() < 1e-9,
466                    "complete reconstruction mismatch at ({i},{j}): {recon} vs {aij}"
467                );
468            }
469        }
470    }
471
472    #[test]
473    fn apply_is_exact_inverse_for_complete_factor() {
474        // For the complete factor, apply(r) == A⁻¹ r, hence A·apply(r) ≈ r.
475        let n = 9;
476        let a = spd_dense_like(n);
477        let fac = ic_k(&a, n + 5).expect("complete");
478        let r: Vec<f64> = (0..n).map(|i| 1.0 + i as f64 * 0.3).collect();
479        let z = fac.apply(&r);
480        let az = a.matvec(&z);
481        for i in 0..n {
482            assert!(
483                (az[i] - r[i]).abs() < 1e-8,
484                "A·apply(r) != r at {i}: {} vs {}",
485                az[i],
486                r[i]
487            );
488        }
489    }
490
491    #[test]
492    fn apply_solves_laplacian_with_complete_fill() {
493        // Complete factor of the 1D Laplacian solves L Lᵀ z = r exactly.
494        let n = 12;
495        let a = laplacian_1d(n);
496        let fac = ic_k(&a, n + 1).expect("complete");
497        let r = vec![1.0f64; n];
498        let z = fac.apply(&r);
499        let az = a.matvec(&z);
500        for i in 0..n {
501            assert!((az[i] - r[i]).abs() < 1e-9);
502        }
503    }
504
505    #[test]
506    fn non_spd_errors() {
507        // Indefinite symmetric matrix: [[1, 2], [2, 1]] has eigenvalues 3, -1.
508        let a = HostCsr::new(
509            2,
510            2,
511            vec![0, 2, 4],
512            vec![0, 1, 0, 1],
513            vec![1.0, 2.0, 2.0, 1.0],
514        )
515        .expect("valid");
516        assert!(matches!(ic_k(&a, 5), Err(SparseError::SingularMatrix)));
517    }
518
519    #[test]
520    fn rejects_non_square() {
521        let a = HostCsr::new(2, 3, vec![0, 1, 2], vec![0, 1], vec![1.0, 1.0]).expect("valid");
522        assert!(matches!(
523            ic_k(&a, 0),
524            Err(SparseError::DimensionMismatch(_))
525        ));
526    }
527
528    #[test]
529    fn ic0_pattern_equals_lower_triangle() {
530        // IC(0) keeps exactly the lower triangle of A's structure.
531        let a = laplacian_2d(4, 4);
532        let p0 = ic_k_symbolic(&a, 0);
533        for (i, prow) in p0.iter().enumerate() {
534            let cols: Vec<usize> = prow.iter().map(|&(c, _)| c).collect();
535            // Every original lower entry must be present, and no extra fill.
536            let mut expected: Vec<usize> = Vec::new();
537            let s = a.row_ptr[i];
538            let e = a.row_ptr[i + 1];
539            for kk in s..e {
540                let j = a.col_indices[kk];
541                if j <= i {
542                    expected.push(j);
543                }
544            }
545            if !expected.contains(&i) {
546                expected.push(i);
547            }
548            expected.sort_unstable();
549            assert_eq!(cols, expected, "row {i}");
550        }
551    }
552}