Skip to main content

bixverse_core/core/math/
sparse.rs

1//! Sparse matrix formats, sparse operations and helpers to transform different
2//! formats into each other.
3
4use crate::utils::gemm::gemm;
5use faer::{Accum, Mat, MatMut, MatRef, Par};
6use num_traits::ToPrimitive;
7use rand::rngs::StdRng;
8use rand::{Rng, SeedableRng};
9use rayon::prelude::*;
10use rustc_hash::FxHashMap;
11
12pub use bixverse_sparse::sparse::*;
13
14use crate::core::math::pca_svd::SvdResults;
15use crate::core::math::vector_helpers::sum_sq_f64;
16use crate::prelude::*;
17use crate::utils::faer_parallelism;
18use crate::utils::simd::{sum_squared_dev_widen_simd_f32, sum_widen_simd_f32};
19
20////////////////////////
21// Dense construction //
22////////////////////////
23
24/// Builds a [CompressedSparseData2] from a dense `faer` matrix.
25///
26/// An extension trait rather than an inherent method, as the sparse structure
27/// lives in `bixverse-sparse`, which does not depend on `faer`.
28pub trait SparseFromDense<T> {
29    /// Generates a sparse matrix from a dense matrix
30    ///
31    /// ### Params
32    ///
33    /// * `mat`: The dense matrix to convert to a sparse matrix
34    /// * `format`: The format of the sparse matrix to generate
35    ///
36    /// ### Returns
37    ///
38    /// * `Self`: The sparse matrix generated from the dense matrix
39    fn from_dense_matrix(mat: MatRef<T>, format: CompressedSparseFormat) -> Self;
40}
41
42impl<T, U> SparseFromDense<T> for CompressedSparseData2<T, U>
43where
44    T: BixverseFloat + BixverseNumeric,
45    U: BixverseNumeric,
46{
47    fn from_dense_matrix(mat: MatRef<T>, format: CompressedSparseFormat) -> Self {
48        Self::from_entry_fn(mat.nrows(), mat.ncols(), format, |i, j| mat[(i, j)])
49    }
50}
51
52/////////////////////////////
53// Sparse dense operations //
54/////////////////////////////
55
56//////////////////////////
57// Sparse dense: consts //
58//////////////////////////
59
60/// Output rows accumulated per tile in [`csr_sparse_matmul_dense`]
61const MATMUL_DENSE_ROW_BLOCK: usize = 256;
62
63////////////////////////////////////
64// Sparse dense: public functions //
65////////////////////////////////////
66
67/// Sparse CSR @ sparse CSR -> dense
68///
69/// Parallel over output rows. For cases where you assume that the resulting
70/// matrix will become dense.
71///
72/// ### Params
73///
74/// * `a` - First matrix
75/// * `b` - Second matrix
76///
77/// ### Returns
78///
79/// Dense matrix product of both CSR matrices
80pub fn csr_sparse_matmul_dense<T>(
81    a: &CompressedSparseData2<T>,
82    b: &CompressedSparseData2<T>,
83) -> Result<Mat<T>, CoreErrors>
84where
85    T: BixverseFloat + Send + Sync + Default,
86{
87    require_matmul_operands(a, b)?;
88
89    let n_rows = a.nrows();
90    let n_cols = b.ncols();
91    let m_indptr = &a.indptr;
92    let m_indices = &a.indices;
93    let m_data = &a.data;
94    let g_indptr = &b.indptr;
95    let g_indices = &b.indices;
96    let g_data = &b.data;
97
98    // Row blocks are accumulated into a row-major tile, then transposed into
99    // the column-major output in parallel over output columns.
100    let mut out = Mat::<T>::zeros(n_rows, n_cols);
101    for block_start in (0..n_rows).step_by(MATMUL_DENSE_ROW_BLOCK) {
102        let rows = MATMUL_DENSE_ROW_BLOCK.min(n_rows - block_start);
103        let mut tile = vec![T::zero(); rows * n_cols];
104        tile.par_chunks_mut(n_cols.max(1))
105            .take(rows)
106            .enumerate()
107            .for_each(|(r, row)| {
108                let i = block_start + r;
109                for p in m_indptr[i] as usize..m_indptr[i + 1] as usize {
110                    let j = m_indices[p] as usize;
111                    let w = m_data[p];
112                    let gs = g_indptr[j] as usize;
113                    let ge = g_indptr[j + 1] as usize;
114                    for q in gs..ge {
115                        let t = g_indices[q] as usize;
116                        row[t] += w * g_data[q];
117                    }
118                }
119            });
120
121        out.par_col_iter_mut().enumerate().for_each(|(j, mut col)| {
122            for r in 0..rows {
123                col[block_start + r] = tile[r * n_cols + j];
124            }
125        });
126    }
127
128    Ok(out)
129}
130
131/// Apply a CSR operator to a dense row-major block: `out = a @ block`
132///
133/// The kernel behind repeated diffusion, `T @ (T @ (T @ X))`. Applying the
134/// operator `t` times is always preferable to forming `T^t`: at `knn = 30` the
135/// operator carries ~30 non-zeros per row, `T^2` ~900 and `T^3` ~27,000, so at
136/// 100k rows the explicit cube is on the order of 2.7e9 non-zeros while `t`
137/// applications stay at ~30 per row for the same answer.
138///
139/// Both buffers are flat row-major rather than [faer::Mat] on purpose. The
140/// operation is `out_row_i = sum_j w_ij * block_row_j`, so a row-major layout
141/// makes every inner step a contiguous SIMD axpy of `width` elements, where
142/// faer's column-major storage would turn it into a strided scatter.
143///
144/// Parallel over output rows, which are disjoint, so no synchronisation.
145///
146/// Note that `single_cell::sc_analysis::meld` has an older private
147/// column-at-a-time version of this (`chebyshev_apply_columns`) that restreams
148/// the whole operator once per column. It should move over here at some point.
149///
150/// ### Params
151///
152/// * `a` - The operator, CSR, `n x k`
153/// * `block` - Dense input, row-major, `k * width` elements
154/// * `width` - Columns in the block
155/// * `out` - Dense output, row-major, `n * width` elements, overwritten
156///
157/// ### Returns
158///
159/// `Ok(())`, or [CoreErrors::SparseMatrixMustBeCsr] if `a` is in CSC, or
160/// [CoreErrors::SparseMatrixMultiplication] if the buffers do not match the
161/// operator's shape.
162///
163/// ### Panics
164///
165/// If `a` violates its own CSR invariants: a column index at or above
166/// `a.shape().1`, or an `indptr` shorter than `a.shape().0 + 1`. Both index
167/// safe slices, so this is a panic rather than unsoundness, but it surfaces
168/// inside a rayon closure. `CompressedSparseData2` constructors do not validate,
169/// so run [assert_invariants](CompressedSparseData2::assert_invariants) on a
170/// matrix of uncertain provenance.
171pub fn csr_matmul_dense_block<T>(
172    a: &CompressedSparseData2<T>,
173    block: &[T],
174    width: usize,
175    out: &mut [T],
176) -> Result<(), CoreErrors>
177where
178    T: BixverseFloat + BixverseSimd,
179{
180    require_csr(a)?;
181
182    let (n_rows, n_cols) = a.shape();
183
184    // `width == 0` would make both buffers empty regardless of the operator, so
185    // the length checks below could not tell a shape mismatch from a no-op.
186    if width == 0 || block.len() != n_cols * width || out.len() != n_rows * width {
187        return Err(CoreErrors::SparseMatrixMultiplication {
188            n_col_a: n_cols,
189            n_row_b: block.len().checked_div(width).unwrap_or(0),
190        });
191    }
192
193    let indptr = &a.indptr;
194    let indices = &a.indices;
195    let data = &a.data;
196
197    out.par_chunks_mut(width)
198        .enumerate()
199        .for_each(|(i, out_row)| {
200            out_row.fill(T::zero());
201            for p in indptr[i] as usize..indptr[i + 1] as usize {
202                let j = indices[p] as usize;
203                T::bxv_axpy_simd(out_row, data[p], &block[j * width..(j + 1) * width]);
204            }
205        });
206
207    Ok(())
208}
209
210///////////////////////
211// Sparse statistics //
212///////////////////////
213
214////////////////////////
215// Statistics: consts //
216////////////////////////
217
218/// Stored non-zeros from which the column statistics fan out over rayon
219const PAR_COLUMN_STATS_MIN_NNZ: usize = 1 << 20;
220
221/////////////////////////
222// Statistics: helpers //
223/////////////////////////
224
225/// The data layer a column statistic runs over.
226///
227/// ### Params
228///
229/// * `csc` - The matrix, which must be CSC
230/// * `use_second_layer` - Use `data_2` instead of `data`
231///
232/// ### Returns
233///
234/// The layer's stored values, or [CoreErrors::SparseMatrixMustBeCsc] /
235/// [CoreErrors::Data2NotAvailable].
236fn csc_layer<T>(csc: &CompressedSparseData2<T>, use_second_layer: bool) -> Result<&[T], CoreErrors>
237where
238    T: BixverseNumeric,
239{
240    require_csc(csc)?;
241
242    if use_second_layer {
243        Ok(csc
244            .data_2
245            .as_ref()
246            .ok_or(CoreErrors::Data2NotAvailable)?
247            .as_slice())
248    } else {
249        Ok(csc.data.as_slice())
250    }
251}
252
253/// Evaluate a per-column statistic for every column, in parallel once the
254/// matrix is large enough.
255///
256/// ### Params
257///
258/// * `ncols` - Number of columns
259/// * `nnz` - Stored values of the layer, gating the parallel path via
260///   [PAR_COLUMN_STATS_MIN_NNZ]
261/// * `stat` - Statistic of column `j`
262///
263/// ### Returns
264///
265/// One value per column.
266fn collect_per_column<T, F>(ncols: usize, nnz: usize, stat: F) -> Vec<T>
267where
268    T: Send,
269    F: Fn(usize) -> T + Sync + Send,
270{
271    if nnz >= PAR_COLUMN_STATS_MIN_NNZ {
272        (0..ncols).into_par_iter().map(stat).collect()
273    } else {
274        (0..ncols).map(stat).collect()
275    }
276}
277
278//////////////////////////////////
279// Statistics: public functions //
280//////////////////////////////////
281
282/// Calculate the column means for CSC [CompressedSparseData2]
283///
284/// ### Params
285///
286/// * `csc` - The [CompressedSparseData2] (needs to have floats)
287/// * `use_second_layer` - Use the second data layer
288///
289/// ### Returns
290///
291/// The column means
292pub fn sparse_col_means_csc<T>(
293    csc: &CompressedSparseData2<T>,
294    use_second_layer: bool,
295) -> Result<Vec<T>, CoreErrors>
296where
297    T: BixverseFloat + BixverseSimd,
298{
299    let active_data = csc_layer(csc, use_second_layer)?;
300
301    let (nrows, ncols) = csc.shape();
302    let nrows_t = T::from_usize(nrows).unwrap();
303
304    Ok(collect_per_column(ncols, active_data.len(), |j| {
305        let start = csc.indptr[j] as usize;
306        let end = csc.indptr[j + 1] as usize;
307        T::bxv_sum(&active_data[start..end]) / nrows_t
308    }))
309}
310
311/// Calculate the column standard deviations for CSC [CompressedSparseData2]
312///
313/// ### Params
314///
315/// * `csc` - The [CompressedSparseData2] (needs to have floats)
316/// * `use_second_layer` - Use the second data layer
317///
318/// ### Returns
319///
320/// The column standard deviations
321pub fn sparse_col_sds_csc<T>(
322    csc: &CompressedSparseData2<T>,
323    use_second_layer: bool,
324) -> Result<Vec<T>, CoreErrors>
325where
326    T: BixverseFloat + BixverseSimd,
327{
328    let active_data = csc_layer(csc, use_second_layer)?;
329
330    let (nrows, ncols) = csc.shape();
331    let nrows_t = T::from_usize(nrows).unwrap();
332    let denom = nrows_t - T::one();
333
334    Ok(collect_per_column(ncols, active_data.len(), |j| {
335        let start = csc.indptr[j] as usize;
336        let end = csc.indptr[j + 1] as usize;
337        let col_slice = &active_data[start..end];
338        let implicit_zeros = T::from_usize(nrows - (end - start)).unwrap();
339
340        let mean = T::bxv_sum(col_slice) / nrows_t;
341        let ssd_nonzero = T::bxv_sum_squared_deviation(col_slice, mean);
342        let ssd_total = ssd_nonzero + implicit_zeros * mean * mean;
343        (ssd_total / denom).sqrt()
344    }))
345}
346
347/// Compute Frobenius norm of sparse matrix
348///
349/// Accumulated in `f64` via [`sum_sq_f64`] and narrowed once at the end. Summing
350/// in `f32` drifts by percentage points over tens of millions of non-zeros, and
351/// the square root then hides how far off it was.
352///
353/// ### Params
354///
355/// * `mat` - Sparse matrix in CSR or CSC format
356///
357/// ### Returns
358///
359/// Frobenius norm ||A||_F = sqrt(sum(A_ij^2))
360pub fn frobenius_norm<T>(mat: &CompressedSparseData2<T>) -> f32
361where
362    T: BixverseNumeric + ToPrimitive,
363{
364    sum_sq_f64(&mat.data).sqrt() as f32
365}
366
367/// Squared Frobenius norm of a sparse matrix, in `f64`.
368///
369/// Prefer this over squaring [`frobenius_norm`] wherever the result is one of
370/// several large terms that cancel: narrowing to `f32` and squaring again throws
371/// away the precision the cancellation needs.
372///
373/// ### Params
374///
375/// * `mat` - Sparse matrix in CSR or CSC format
376///
377/// ### Returns
378///
379/// `||A||_F^2`.
380pub fn frobenius_norm_sq_f64<T>(mat: &CompressedSparseData2<T>) -> f64
381where
382    T: BixverseNumeric + ToPrimitive,
383{
384    sum_sq_f64(&mat.data)
385}
386
387/// Counts the zeroes in a given faer matrix
388///
389/// ### Params
390///
391/// * `mat` - The respective faer matrix
392///
393/// ### Returns
394///
395/// A tuple with the first being the total zeroes, the second the zeroes per
396/// row and the last element being the column zeroes.
397pub fn count_zeroes<T>(mat: &MatRef<T>) -> (usize, Vec<usize>, Vec<usize>)
398where
399    T: BixverseFloat,
400{
401    let (nrow, ncol) = mat.shape();
402    let mut total_zeroes = 0_usize;
403    let mut row_zeroes = vec![0_usize; nrow];
404    let mut col_zeroes = vec![0_usize; ncol];
405
406    let zero = T::zero();
407
408    for j in 0..ncol {
409        for i in 0..nrow {
410            let val = unsafe { mat.get_unchecked(i, j) };
411            if *val == zero {
412                total_zeroes += 1;
413                row_zeroes[i] += 1;
414                col_zeroes[j] += 1;
415            }
416        }
417    }
418
419    (total_zeroes, row_zeroes, col_zeroes)
420}
421
422/////////////////////////
423// Sparse correlations //
424/////////////////////////
425
426//////////////////////////
427// Correlations: consts //
428//////////////////////////
429
430/// Below this a column counts as constant and its correlations are reported as
431/// `0.0` rather than as a division by a vanishing standard deviation.
432const SPARSE_COR_SD_EPS: f64 = 1e-8;
433
434///////////////////////////
435// Correlations: structs //
436///////////////////////////
437
438/// One sparse column reduced to what [`sparse_pairwise_correlations`] needs.
439///
440/// The stored entries are kept as they arrive; nothing here densifies, so the
441/// memory is `O(nnz)` per column rather than `O(n_rows)`.
442#[derive(Clone, Debug)]
443pub struct SparseColMoments {
444    /// Row indices of the stored entries. Ascending order is not required.
445    pub indices: Vec<u32>,
446    /// Values at those indices. The raw values for Pearson, the zero-shifted
447    /// ranks for Spearman. Every non-stored position is exactly zero in both
448    /// cases, which is what makes the closed forms below valid.
449    pub values: Vec<f32>,
450    /// Mean over all `n_rows` entries, structural zeros included.
451    pub mean: f64,
452    /// Standard deviation over all `n_rows` entries, sample (n - 1)
453    /// denominator.
454    pub sd: f64,
455}
456
457///////////////////////////
458// Correlations: helpers //
459///////////////////////////
460
461/// Average ranks of one sparse column, shifted so the zero block sits at zero.
462///
463/// ### Params
464///
465/// * `values` - The stored values of the column.
466/// * `n_rows` - Full length of the column, structural zeros included.
467///
468/// ### Returns
469///
470/// The shifted average ranks, aligned to `values`.
471fn shifted_ranks_sparse(values: &[f32], n_rows: usize) -> Vec<f32> {
472    let n_stored = values.len();
473    let n_implicit = n_rows.saturating_sub(n_stored);
474
475    let n_neg = values.iter().filter(|&&v| v < 0.0).count();
476    let n_stored_zero = values.iter().filter(|&&v| v == 0.0).count();
477    let zero_block = n_stored_zero + n_implicit;
478
479    // The zero group occupies 1-based ranks (n_neg + 1) ..= (n_neg + zero_block).
480    let r_zero = n_neg as f64 + (zero_block as f64 + 1.0) / 2.0;
481
482    let mut order: Vec<u32> = (0..n_stored as u32).collect();
483    order.sort_unstable_by(|&a, &b| {
484        values[a as usize]
485            .partial_cmp(&values[b as usize])
486            .unwrap_or(std::cmp::Ordering::Equal)
487    });
488
489    let mut ranks = vec![0_f32; n_stored];
490    let mut i = 0;
491    while i < n_stored {
492        let value = values[order[i] as usize];
493        let start = i;
494        while i < n_stored && values[order[i] as usize] == value {
495            i += 1;
496        }
497
498        let avg_rank = if value == 0.0 {
499            r_zero
500        } else {
501            // A stored value at sorted position `p` sits at 1-based full rank
502            // `p + 1` when negative, and `p + 1 + n_implicit` when positive:
503            // the implicit zeros are spliced in ahead of it.
504            let offset = if value < 0.0 { 0 } else { n_implicit };
505            (start + i + 1 + 2 * offset) as f64 / 2.0
506        };
507
508        let shifted = (avg_rank - r_zero) as f32;
509        for &slot in &order[start..i] {
510            ranks[slot as usize] = shifted;
511        }
512    }
513
514    ranks
515}
516
517////////////////////////////////////
518// Correlations: public functions //
519////////////////////////////////////
520
521/// Reduce one sparse column to its moments without densifying it.
522///
523/// ### Params
524///
525/// * `indices` - Row indices of the stored entries, each below `n_rows` and
526///   without duplicates. Order does not matter.
527/// * `values` - Values at those indices, aligned to `indices`.
528/// * `n_rows` - Full length of the column, structural zeros included. Must be
529///   at least `values.len()`.
530/// * `spearman` - Rank the column first, for Spearman rather than Pearson.
531///
532/// ### Returns
533///
534/// The [`SparseColMoments`] for this column.
535pub fn sparse_col_moments(
536    indices: &[u32],
537    values: &[f32],
538    n_rows: usize,
539    spearman: bool,
540) -> SparseColMoments {
541    let stored: Vec<f32> = if spearman {
542        shifted_ranks_sparse(values, n_rows)
543    } else {
544        values.to_vec()
545    };
546
547    let n = n_rows as f64;
548    let mean = sum_widen_simd_f32(&stored) / n;
549    let ss_stored = sum_squared_dev_widen_simd_f32(&stored, mean);
550    let ss = ss_stored + n_rows.saturating_sub(stored.len()) as f64 * mean * mean;
551    let sd = if n_rows < 2 {
552        0.0
553    } else {
554        (ss / (n - 1.0)).sqrt()
555    };
556
557    SparseColMoments {
558        indices: indices.to_vec(),
559        values: stored,
560        mean,
561        sd,
562    }
563}
564
565/// Pearson correlation of specified column pairs, over the sparsity patterns.
566///
567/// Uses the raw-moment form of the covariance,
568///
569/// ```text
570/// sum (x_a - m_a)(x_b - m_b) = sum x_a x_b - n * m_a * m_b
571/// ```
572///
573/// because `sum x_a x_b` only picks up the intersection of the two sparsity
574/// patterns. On single-cell like counts that is a hundredth of the column
575/// rather than all of it.
576///
577/// ### Params
578///
579/// * `moments` - Per-column moments, see [`sparse_col_moments`].
580/// * `pairs` - Index pairs into `moments`, one per requested correlation.
581/// * `n_rows` - Full column length, structural zeros included.
582///
583/// ### Returns
584///
585/// One correlation per entry of `pairs`, in the same order, clamped to
586/// `[-1, 1]`. A column whose standard deviation is below
587/// [`SPARSE_COR_SD_EPS`] yields `0.0`.
588pub fn sparse_pairwise_correlations(
589    moments: &[SparseColMoments],
590    pairs: &[(usize, usize)],
591    n_rows: usize,
592) -> Vec<f32> {
593    let mut grouped: FxHashMap<usize, Vec<(usize, usize)>> = FxHashMap::default();
594    for (slot, &(a, b)) in pairs.iter().enumerate() {
595        grouped.entry(a).or_default().push((slot, b));
596    }
597    let groups: Vec<(usize, Vec<(usize, usize)>)> = grouped.into_iter().collect();
598
599    let n = n_rows as f64;
600    let denom = n - 1.0;
601
602    let solved: Vec<Vec<(usize, f32)>> = groups
603        .par_iter()
604        .map_init(
605            || vec![0_f32; n_rows],
606            |scratch, (first, partners)| {
607                let a = &moments[*first];
608                for (&idx, &value) in a.indices.iter().zip(a.values.iter()) {
609                    scratch[idx as usize] = value;
610                }
611
612                let out = partners
613                    .iter()
614                    .map(|&(slot, second)| {
615                        let b = &moments[second];
616                        if a.sd < SPARSE_COR_SD_EPS || b.sd < SPARSE_COR_SD_EPS {
617                            return (slot, 0_f32);
618                        }
619                        // scratch is zero wherever `a` has no entry, so the
620                        // product drops out and no branch is needed.
621                        let mut cross = 0_f64;
622                        for (&idx, &value) in b.indices.iter().zip(b.values.iter()) {
623                            cross += scratch[idx as usize] as f64 * value as f64;
624                        }
625                        let cov = cross - n * a.mean * b.mean;
626                        let cor = cov / (denom * a.sd * b.sd);
627                        (slot, (cor as f32).clamp(-1_f32, 1_f32))
628                    })
629                    .collect();
630
631                // Clear only what was written; refilling the whole buffer would
632                // cost `n_rows` per group instead of `nnz`.
633                for &idx in &a.indices {
634                    scratch[idx as usize] = 0_f32;
635                }
636
637                out
638            },
639        )
640        .collect();
641
642    let mut res = vec![0_f32; pairs.len()];
643    for group in solved {
644        for (slot, value) in group {
645            res[slot] = value;
646        }
647    }
648    res
649}
650
651/////////////////////////
652// Lanczos eigensolver //
653/////////////////////////
654
655/////////////////////
656// Lanczos: consts //
657/////////////////////
658
659/// Ritz vectors retained across a restart, above the requested pair count.
660const LANCZOS_RESTART_GUARD: usize = 5;
661
662/// Norm ratio below which a second Gram-Schmidt pass is run.
663///
664/// The DGKS criterion. A drop past `1 / sqrt(2)` means the projection cancelled
665/// at least half the vector, which is where the round-off it leaves behind
666/// stops being negligible. Above that the second pass is measurably wasted work.
667const LANCZOS_ORTHO_REFINE_RATIO: f64 = 0.707;
668
669/// Lanczos breakdown threshold, relative to the running `||A||` estimate.
670///
671/// An absolute threshold is meaningless for a matrix that is not `O(1)`. The
672/// attainable floor on the residual norm after two Gram-Schmidt passes is
673/// `~eps * sqrt(n) * ||A||`, so this sits comfortably above it while still only
674/// firing on a genuinely invariant subspace.
675const LANCZOS_BREAKDOWN_RATIO: f64 = 1e-12;
676
677//////////////////////
678// Lanczos: structs //
679//////////////////////
680
681/// Parameters for the restarted Lanczos eigensolver.
682#[derive(Clone, Copy, Debug)]
683pub struct LanczosParams {
684    /// Krylov basis vectors held at once. `None` derives it from the requested
685    /// component count as `max(2k + 10, k + 20)`, which leaves enough room
686    /// above the wanted pairs for the restart to make progress.
687    pub basis_size: Option<usize>,
688    /// Maximum restart cycles. Each cycle costs one basis worth of
689    /// matrix-vector products plus the orthogonalisation against the basis, so
690    /// this is the only knob that bounds the run time.
691    pub max_restarts: usize,
692    /// Relative residual tolerance. A Ritz pair counts as converged once
693    /// `||A x - lambda x||` drops below `tol * ||A||`. The scaling matters: an
694    /// absolute threshold is unreachable for a matrix whose norm is large and
695    /// trivially met for one whose norm is tiny.
696    pub tol: f64,
697}
698
699impl LanczosParams {
700    /// Defaults tuned on a single cell diffusion kernel.
701    ///
702    /// ### Returns
703    ///
704    /// Self with a derived basis size, 64 restarts and a relative tolerance of
705    /// `1e-8`.
706    pub fn new() -> Self {
707        Self {
708            basis_size: None,
709            max_restarts: 64,
710            tol: 1e-8,
711        }
712    }
713
714    /// Resolve the basis size for a given component count.
715    ///
716    /// ### Params
717    ///
718    /// * `n_components` - Eigenpairs requested, already capped at `n`.
719    /// * `n` - Matrix dimension, which caps the basis.
720    ///
721    /// ### Returns
722    ///
723    /// The number of basis vectors to hold, at least one and never above `n`.
724    fn resolve_basis(&self, n_components: usize, n: usize) -> usize {
725        self.basis_size
726            .unwrap_or_else(|| (n_components * 2 + 10).max(n_components + 20))
727            .max(n_components + 1)
728            .min(n)
729            .max(1)
730    }
731}
732
733impl Default for LanczosParams {
734    fn default() -> Self {
735        Self::new()
736    }
737}
738
739/// Outcome of a restarted Lanczos solve, with the convergence diagnostics.
740///
741/// The diagnostics exist because a budget-exhausted solve and a converged one
742/// are otherwise indistinguishable from the outside, and the difference is
743/// several digits of accuracy in the eigenvalues.
744#[derive(Clone, Debug)]
745pub struct LanczosResult {
746    /// Eigenvalues sorted descending. Kept in `f64` because the callers form
747    /// `lambda / (1 - lambda)`, which is catastrophic in `f32` once `lambda`
748    /// approaches one, as it does for any diffusion operator.
749    pub eigenvalues: Vec<f64>,
750    /// `eigenvectors[i][j]` is element `i` of eigenvector `j`. Always exactly
751    /// `eigenvalues.len()` columns wide.
752    pub eigenvectors: Vec<Vec<f32>>,
753    /// Largest residual `||A x - lambda x||` over the returned pairs.
754    pub residual: f64,
755    /// Scale the residual was tested against, i.e. the `||A||` estimate.
756    pub norm_estimate: f64,
757    /// Whether `residual` met `tol * norm_estimate`.
758    pub converged: bool,
759    /// Restart cycles actually run.
760    pub restarts: usize,
761}
762
763/// Ritz pairs and convergence diagnostics from [lanczos_thick_restart] and
764/// [block_lanczos_thick_restart].
765#[derive(Clone, Debug)]
766pub struct RitzPairs {
767    /// Ritz values sorted descending.
768    pub values: Vec<f64>,
769    /// Unit-norm Ritz vectors, column-major, `n` rows by `values.len()`
770    /// columns.
771    pub vectors: Vec<f64>,
772    /// Largest residual `||A x - lambda x||` over the returned pairs.
773    pub residual: f64,
774    /// Scale the residual was tested against, i.e. the `||A||` estimate.
775    pub norm_estimate: f64,
776    /// Whether `residual` met `tol * norm_estimate`.
777    pub converged: bool,
778    /// Restart cycles actually run.
779    pub restarts: usize,
780}
781
782//////////////////////
783// Lanczos: helpers //
784//////////////////////
785
786/// Helper function for dot product of two vectors
787///
788/// ### Params
789///
790/// * `a` - Vector a
791/// * `b` - Vector b
792///
793/// ### Returns
794///
795/// Dot product of the two vectors
796fn dot<T>(a: &[T], b: &[T]) -> T
797where
798    T: BixverseSimd,
799{
800    assert_same_len!(a, b);
801    T::bxv_dot_simd(a, b)
802}
803
804/// Helper function to normalise a vector
805///
806/// ### Params
807///
808/// * `v` - Initial vector
809///
810/// ### Returns
811///
812/// Normalised dot product of the vector `v`
813fn norm<T>(v: &[T]) -> T
814where
815    T: BixverseSimd + BixverseFloat,
816{
817    let dot = dot(v, v);
818    dot.sqrt()
819}
820
821/// Helper function to normalise a vector
822///
823/// ### Params
824///
825/// * `v` - Mutable reference of the vector to normalise
826fn normalise<T>(v: &mut [T])
827where
828    T: BixverseSimd + BixverseFloat,
829{
830    let n = norm(v);
831    v.par_iter_mut().for_each(|x| *x /= n);
832}
833
834/// Largest eigenpairs of a symmetric operator via thick-restarted Lanczos.
835///
836/// Each cycle builds a Krylov basis of
837/// [LanczosParams::basis_size] vectors with full reorthogonalisation, does a
838/// Rayleigh-Ritz extraction, and restarts from the best Ritz vectors plus the
839/// residual direction. Restarting is the key point: the basis stays bounded
840/// while the iteration keeps going, so a clustered spectrum converges without
841/// the quadratic orthogonalisation cost a single long run would need.
842///
843/// The projected matrix is accumulated explicitly as `H[i][j] = <v_i, A v_j>`
844/// rather than as a tridiagonal recurrence. After a thick restart the leading
845/// basis vectors are Ritz vectors, not Lanczos vectors, so the projection is
846/// arrow-shaped rather than tridiagonal; forming it directly handles that
847/// without special-casing.
848///
849/// The basis is held column-major as one flat buffer, so the projection, the
850/// reorthogonalisation, the restart and the final Ritz vectors are all faer
851/// products rather than scalar loops. Orthogonalisation is classical
852/// Gram-Schmidt with a DGKS-conditional second pass, which matches the accuracy
853/// of two unconditional modified passes while being expressible as two matrix
854/// products instead of 2 * basis_size scalar loops.
855///
856/// Convergence uses the exact Arnoldi residual |beta_m| * |y[m-1]|, which
857/// still holds under thick restart because the relation A V = V H + beta v
858/// e^T is preserved across cycles, tested against tol * ||A||.
859///
860/// Internals are f64 regardless of the caller's float type, the operator
861/// included.
862///
863/// ### Params
864///
865/// * `n` - Dimension of the operator.
866/// * `n_components` - Number of eigenpairs to compute. Capped at `n`.
867/// * `seed` - Seed for the starting vector.
868/// * `params` - [LanczosParams] for basis size, restarts and tolerance.
869/// * `matvec` - Symmetric operator, writes `A x` into `y`.
870///
871/// ### Returns
872///
873/// [RitzPairs] with at most `min(n_components, n)` pairs, fewer if the
874/// iteration hit an invariant subspace first.
875fn lanczos_thick_restart<M>(
876    n: usize,
877    n_components: usize,
878    seed: u64,
879    params: LanczosParams,
880    matvec: M,
881) -> Result<RitzPairs, CoreErrors>
882where
883    M: Fn(&[f64], &mut [f64]),
884{
885    if n_components == 0 || n == 0 {
886        return Err(CoreErrors::MustBePositive(
887            "n_components and the matrix dimension".to_string(),
888        ));
889    }
890
891    let n_keep = n_components.min(n);
892    let m = params.resolve_basis(n_keep, n);
893
894    // Column-major basis, one flat buffer, so every pass over it is a `faer`
895    // product and column `j` is still a contiguous slice for the matrix product.
896    let mut basis = vec![0.0f64; m * n];
897    let mut h = Mat::<f64>::zeros(m, m);
898    let mut w = vec![0.0f64; n];
899    let mut coeffs = vec![0.0f64; m];
900
901    let mut rng = StdRng::seed_from_u64(seed);
902    for x in basis[..n].iter_mut() {
903        *x = rng.random::<f64>() - 0.5;
904    }
905    normalise(&mut basis[..n]);
906
907    // Basis vectors carried over from the previous cycle. Zero on the first.
908    let mut locked = 0usize;
909    let mut ritz_values: Vec<f64> = Vec::new();
910    let mut ritz_coeffs = Mat::<f64>::zeros(0, 0);
911    let mut m_final = 0usize;
912
913    // Lower bound on ||A||_2, refined as the iteration sees more of the matrix.
914    let mut norm_estimate = 0.0f64;
915    let mut residual = f64::INFINITY;
916    let mut converged = false;
917    let mut restarts = 0usize;
918
919    let budget = params.max_restarts.max(1);
920
921    for cycle in 0..budget {
922        restarts = cycle + 1;
923        let mut m_eff = m;
924        let mut last_beta = 0.0f64;
925
926        for j in locked..m {
927            matvec(&basis[j * n..(j + 1) * n], &mut w);
928
929            let width = j + 1;
930
931            // Rayleigh-Ritz coefficients against the whole basis so far. Taken
932            // before any subtraction, so `h` is the exact projection, and they
933            // double as the first Gram-Schmidt coefficient set.
934            basis_project(&basis, n, width, &w, &mut coeffs[..width]);
935            for i in 0..width {
936                h[(i, j)] = coeffs[i];
937                h[(j, i)] = coeffs[i];
938            }
939
940            let before = norm(&w);
941            norm_estimate = norm_estimate.max(before);
942
943            basis_subtract(&basis, n, width, &coeffs[..width], &mut w);
944            last_beta = norm(&w);
945
946            // DGKS: refine only when the projection cancelled enough of the
947            // vector for the round-off it left behind to matter.
948            if last_beta < LANCZOS_ORTHO_REFINE_RATIO * before {
949                basis_project(&basis, n, width, &w, &mut coeffs[..width]);
950                basis_subtract(&basis, n, width, &coeffs[..width], &mut w);
951                last_beta = norm(&w);
952            }
953
954            // Breakdown: the basis already spans an invariant subspace, so the
955            // Ritz pairs from it are exact and there is nothing left to add.
956            if last_beta <= LANCZOS_BREAKDOWN_RATIO * norm_estimate {
957                m_eff = width;
958                last_beta = 0.0;
959                break;
960            }
961
962            if width < m {
963                let inv = 1.0 / last_beta;
964                let next = &mut basis[width * n..(width + 1) * n];
965                for (dst, &src) in next.iter_mut().zip(w.iter()) {
966                    *dst = src * inv;
967                }
968            }
969        }
970
971        let (values, vectors) = symmetric_eigen_descending(h.as_ref(), m_eff)?;
972
973        // Residual of Ritz pair i is |beta| * |y[m_eff - 1, i]|, exact under the
974        // Arnoldi relation that thick restart preserves.
975        let n_out = n_keep.min(m_eff);
976        residual = (0..n_out).fold(0.0f64, |acc, i| {
977            acc.max(last_beta * vectors[(m_eff - 1, i)].abs())
978        });
979
980        // The extreme Ritz values are the sharpest available lower bound on
981        // ||A||, so scale the tolerance by them rather than testing an absolute
982        // threshold that a large matrix can never reach.
983        norm_estimate = norm_estimate
984            .max(values[0].abs())
985            .max(values[m_eff - 1].abs());
986        converged = residual <= params.tol * norm_estimate;
987
988        ritz_values = values;
989        ritz_coeffs = vectors;
990        m_final = m_eff;
991
992        if converged || m_eff < m || last_beta == 0.0 || cycle + 1 == budget {
993            break;
994        }
995
996        // Thick restart: keep the best Ritz vectors, then hand the residual
997        // direction over as the next basis vector so the Krylov space continues
998        // to grow from where it stopped.
999        // Never retain more than half the basis: past that the cycle buys too
1000        // few new Krylov vectors to pay for the restart, and at `keep = m - 1`
1001        // it buys exactly one and the iteration effectively stops.
1002        let keep = (n_keep + LANCZOS_RESTART_GUARD).min(m / 2).max(1);
1003        let mut restarted = vec![0.0f64; keep * n];
1004        basis_expand(&basis, n, m_eff, ritz_coeffs.as_ref(), keep, &mut restarted);
1005
1006        let inv = 1.0 / last_beta;
1007        for x in w.iter_mut() {
1008            *x *= inv;
1009        }
1010
1011        h.fill(0.0);
1012        basis[..keep * n].copy_from_slice(&restarted);
1013        for (i, &value) in ritz_values.iter().take(keep).enumerate() {
1014            h[(i, i)] = value;
1015        }
1016        basis[keep * n..(keep + 1) * n].copy_from_slice(&w);
1017        locked = keep;
1018    }
1019
1020    // Ritz vectors are built once, on the way out. Building them every cycle is
1021    // pure waste: only the last cycle's are ever returned, and the retained
1022    // block of the restart is bit-identical to the leading columns anyway.
1023    let n_out = n_keep.min(m_final);
1024    let mut ritz = vec![0.0f64; n_out * n];
1025    basis_expand(&basis, n, m_final, ritz_coeffs.as_ref(), n_out, &mut ritz);
1026
1027    for column in ritz.chunks_exact_mut(n) {
1028        let scale: f64 = column.iter().map(|v| v * v).sum::<f64>().sqrt();
1029        let inv = if scale > 0.0 { 1.0 / scale } else { 0.0 };
1030        column.iter_mut().for_each(|v| *v *= inv);
1031    }
1032
1033    Ok(RitzPairs {
1034        values: ritz_values[..n_out].to_vec(),
1035        vectors: ritz,
1036        residual,
1037        norm_estimate,
1038        converged,
1039        restarts,
1040    })
1041}
1042
1043/// Project a vector onto the leading columns of the basis.
1044///
1045/// ### Params
1046///
1047/// * `basis` - Column-major basis buffer with `n` rows.
1048/// * `n` - Length of a basis vector.
1049/// * `width` - Leading columns to project against.
1050/// * `v` - Vector of length `n`.
1051/// * `out` - Receives `V^T v`; must be exactly `width` long.
1052///
1053/// ### Returns
1054///
1055/// Nothing; `out` is overwritten.
1056fn basis_project(basis: &[f64], n: usize, width: usize, v: &[f64], out: &mut [f64]) {
1057    let b = MatRef::from_column_major_slice(&basis[..width * n], n, width);
1058    let v = MatRef::from_column_major_slice(v, n, 1);
1059    let mut out = MatMut::from_column_major_slice_mut(out, width, 1);
1060
1061    gemm(
1062        out.as_mut(),
1063        Accum::Replace,
1064        b.transpose(),
1065        v,
1066        1.0,
1067        faer_parallelism(),
1068    );
1069}
1070
1071/// Subtract the basis expansion of a coefficient vector in place.
1072///
1073/// ### Params
1074///
1075/// * `basis` - Column-major basis buffer with `n` rows.
1076/// * `n` - Length of a basis vector.
1077/// * `width` - Leading columns to expand.
1078/// * `coeffs` - Coefficients, exactly `width` long.
1079/// * `v` - Vector of length `n`, updated to `v - V c`.
1080///
1081/// ### Returns
1082///
1083/// Nothing; `v` is updated in place.
1084fn basis_subtract(basis: &[f64], n: usize, width: usize, coeffs: &[f64], v: &mut [f64]) {
1085    let b = MatRef::from_column_major_slice(&basis[..width * n], n, width);
1086    let c = MatRef::from_column_major_slice(coeffs, width, 1);
1087    let mut v = MatMut::from_column_major_slice_mut(v, n, 1);
1088
1089    gemm(v.as_mut(), Accum::Add, b, c, -1.0, faer_parallelism());
1090}
1091
1092/// Expand Ritz coefficient columns back into the full space.
1093///
1094/// ### Params
1095///
1096/// * `basis` - Column-major basis buffer with `n` rows.
1097/// * `n` - Length of a basis vector.
1098/// * `width` - Basis columns spanned by the coefficients.
1099/// * `coeffs` - Rayleigh-Ritz eigenvectors, column-wise.
1100/// * `n_out` - Leading coefficient columns to expand.
1101/// * `out` - Receives the `n_out` expanded vectors, column-major.
1102///
1103/// ### Returns
1104///
1105/// Nothing; `out` is overwritten.
1106fn basis_expand(
1107    basis: &[f64],
1108    n: usize,
1109    width: usize,
1110    coeffs: MatRef<f64>,
1111    n_out: usize,
1112    out: &mut [f64],
1113) {
1114    let b = MatRef::from_column_major_slice(&basis[..width * n], n, width);
1115    let mut out = MatMut::from_column_major_slice_mut(out, n, n_out);
1116
1117    gemm(
1118        out.as_mut(),
1119        Accum::Replace,
1120        b,
1121        coeffs.subcols(0, n_out),
1122        1.0,
1123        faer_parallelism(),
1124    );
1125}
1126
1127/// Eigen-decompose the leading `size` block of a symmetric matrix, descending.
1128///
1129/// ### Params
1130///
1131/// * `h` - Symmetric matrix; only the leading `size` by `size` block is read.
1132/// * `size` - Size of the block to decompose.
1133///
1134/// ### Returns
1135///
1136/// `(eigenvalues, eigenvectors)` sorted by descending eigenvalue, the
1137/// eigenvectors held column-wise.
1138pub(crate) fn symmetric_eigen_descending(
1139    h: MatRef<f64>,
1140    size: usize,
1141) -> Result<(Vec<f64>, Mat<f64>), CoreErrors> {
1142    let block = Mat::<f64>::from_fn(size, size, |i, j| h[(i, j)]);
1143
1144    let eig = block
1145        .self_adjoint_eigen(faer::Side::Lower)
1146        .map_err(|_| CoreErrors::FaerEigenError)?;
1147
1148    let raw_values = eig.S();
1149    let raw_vectors = eig.U();
1150
1151    let mut order: Vec<usize> = (0..size).collect();
1152    order.sort_by(|&a, &b| raw_values[b].total_cmp(&raw_values[a]));
1153
1154    let values: Vec<f64> = order.iter().map(|&i| raw_values[i]).collect();
1155    let vectors = Mat::<f64>::from_fn(size, size, |i, j| raw_vectors[(i, order[j])]);
1156
1157    Ok((values, vectors))
1158}
1159
1160///////////////////////////////
1161// Lanczos: public functions //
1162///////////////////////////////
1163
1164/// Compute the largest eigenpairs of a symmetric sparse matrix.
1165///
1166/// Thin wrapper over [compute_largest_eigenpairs_lanczos_diag] for callers that
1167/// do not inspect the convergence diagnostics.
1168///
1169/// ### Params
1170///
1171/// * `matrix` - Symmetric sparse matrix. CSC input is converted to CSR.
1172/// * `n_components` - Number of eigenpairs to compute.
1173/// * `seed` - Seed for the starting vector.
1174/// * `params` - Optional [LanczosParams], defaulted when `None`.
1175///
1176/// ### Returns
1177///
1178/// `(eigenvalues, eigenvectors)` sorted by descending eigenvalue, where
1179/// `eigenvectors[i][j]` is element `i` of eigenvector `j`. Both outputs carry
1180/// the same number of components, which is `min(n_components, n)` and can be
1181/// smaller still if the iteration hit an invariant subspace first.
1182pub fn compute_largest_eigenpairs_lanczos<T>(
1183    matrix: &CompressedSparseData2<T>,
1184    n_components: usize,
1185    seed: u64,
1186    params: Option<LanczosParams>,
1187) -> Result<(Vec<f64>, Vec<Vec<f32>>), CoreErrors>
1188where
1189    T: BixverseNumeric + BixverseSimd + Into<f64>,
1190{
1191    let res = compute_largest_eigenpairs_lanczos_diag(matrix, n_components, seed, params)?;
1192    Ok((res.eigenvalues, res.eigenvectors))
1193}
1194
1195/// Compute the largest eigenpairs of a symmetric sparse matrix, with
1196/// diagnostics.
1197///
1198/// Thick-restarted Lanczos, see [lanczos_thick_restart] for the algorithm.
1199///
1200/// ### Params
1201///
1202/// * `matrix` - Symmetric sparse matrix. CSC input is converted to CSR.
1203/// * `n_components` - Number of eigenpairs to compute. Capped at the matrix
1204///   dimension rather than erroring, so a request for more pairs than the
1205///   matrix has simply returns all of them.
1206/// * `seed` - Seed for the starting vector.
1207/// * `params` - Optional [LanczosParams], defaulted when None.
1208///
1209/// ### Returns
1210///
1211/// A [LanczosResult]. The eigenvalue count and the eigenvector column count
1212/// are always equal.
1213pub fn compute_largest_eigenpairs_lanczos_diag<T>(
1214    matrix: &CompressedSparseData2<T>,
1215    n_components: usize,
1216    seed: u64,
1217    params: Option<LanczosParams>,
1218) -> Result<LanczosResult, CoreErrors>
1219where
1220    T: BixverseNumeric + BixverseSimd + Into<f64>,
1221{
1222    let n = matrix.shape.0;
1223
1224    // CSR gives contiguous rows for the matrix-vector product.
1225    let csr = match matrix.cs_type {
1226        CompressedSparseFormat::Csr => matrix.clone(),
1227        CompressedSparseFormat::Csc => matrix.transform(),
1228    };
1229    let data_f64: Vec<f64> = csr.data.iter().map(|&v| v.into()).collect();
1230
1231    let matvec = |x: &[f64], y: &mut [f64]| {
1232        y.par_iter_mut().enumerate().for_each(|(i, yi)| {
1233            let mut sum = 0.0;
1234            for idx in csr.indptr[i] as usize..csr.indptr[i + 1] as usize {
1235                let j = csr.indices[idx] as usize;
1236                sum += data_f64[idx] * x[j];
1237            }
1238            *yi = sum;
1239        });
1240    };
1241
1242    let ritz = lanczos_thick_restart(n, n_components, seed, params.unwrap_or_default(), matvec)?;
1243
1244    let n_out = ritz.values.len();
1245    let mut eigenvectors = vec![vec![0.0f32; n_out]; n];
1246    for comp in 0..n_out {
1247        let column = &ritz.vectors[comp * n..(comp + 1) * n];
1248        for (point, &v) in column.iter().enumerate() {
1249            eigenvectors[point][comp] = v as f32;
1250        }
1251    }
1252
1253    Ok(LanczosResult {
1254        eigenvalues: ritz.values,
1255        eigenvectors,
1256        residual: ritz.residual,
1257        norm_estimate: ritz.norm_estimate,
1258        converged: ritz.converged,
1259        restarts: ritz.restarts,
1260    })
1261}
1262
1263///////////////////
1264// Block Lanczos //
1265///////////////////
1266
1267///////////////////////////
1268// Block Lanczos: consts //
1269///////////////////////////
1270
1271/// New blocks per restart cycle the derived basis leaves room for.
1272///
1273/// With a single new block per cycle the iteration degenerates into subspace
1274/// iteration with Rayleigh-Ritz, which crawls on a clustered spectrum. Not
1275/// tuned beyond that.
1276const BLOCK_LANCZOS_MIN_NEW_BLOCKS: usize = 3;
1277
1278////////////////////////////
1279// Block Lanczos: structs //
1280////////////////////////////
1281
1282/// Parameters for the block thick-restart Lanczos eigensolver.
1283#[derive(Clone, Copy, Debug)]
1284pub struct BlockLanczosParams {
1285    /// Vectors per block, i.e. the width of every operator call. Wider blocks
1286    /// mean fewer, larger calls, which is what pays where each call carries a
1287    /// fixed launch and readback cost. Capped at half the operator dimension.
1288    pub block_size: usize,
1289    /// Krylov basis vectors held at once, rounded down to whole blocks. `None`
1290    /// derives it as for [LanczosParams], rounded up to whole blocks, at least
1291    /// twice the retained Ritz block and with room for
1292    /// `BLOCK_LANCZOS_MIN_NEW_BLOCKS` new blocks per cycle.
1293    pub basis_size: Option<usize>,
1294    /// Maximum restart cycles.
1295    pub max_restarts: usize,
1296    /// Relative residual tolerance, tested against `tol * ||A||` as in
1297    /// [LanczosParams::tol].
1298    pub tol: f64,
1299}
1300
1301impl BlockLanczosParams {
1302    /// Defaults, chosen for the cubecl GPU path. Backends whose per-call cost
1303    /// grows faster with the block width override the block size.
1304    ///
1305    /// ### Returns
1306    ///
1307    /// Self with blocks of 32, a derived basis size, 64 restarts and a
1308    /// relative tolerance of `1e-6`. That is tight enough that an f32
1309    /// operator's singular values agree with an f64 solve to f32 precision,
1310    /// while the scalar solver's `1e-8` sits near the f32 round-off and would
1311    /// mostly burn the restart budget.
1312    pub fn new() -> Self {
1313        Self {
1314            block_size: 32,
1315            basis_size: None,
1316            max_restarts: 64,
1317            tol: 1e-6,
1318        }
1319    }
1320
1321    /// Resolve block width, basis size and retained Ritz vectors.
1322    ///
1323    /// Everything is a whole number of blocks, so the operator is only ever
1324    /// called at full width.
1325    ///
1326    /// ### Params
1327    ///
1328    /// * `n_keep` - Eigenpairs requested, already capped at `n`.
1329    /// * `n` - Operator dimension.
1330    ///
1331    /// ### Returns
1332    ///
1333    /// `(block, basis, keep)`. `basis + block > n` means the operator is too
1334    /// small for Lanczos to pay, see [block_lanczos_thick_restart].
1335    fn resolve(&self, n_keep: usize, n: usize) -> (usize, usize, usize) {
1336        let b = self.block_size.min(n / 2).max(1);
1337        let round_up = |x: usize| x.div_ceil(b) * b;
1338        let floor = |x: usize| (x / b) * b;
1339
1340        let keep_target = round_up(n_keep + LANCZOS_RESTART_GUARD);
1341        let basis = match self.basis_size {
1342            Some(size) => floor(size),
1343            None => round_up((n_keep * 2 + 10).max(n_keep + 20))
1344                .max(2 * keep_target)
1345                .max(keep_target + BLOCK_LANCZOS_MIN_NEW_BLOCKS * b),
1346        }
1347        .max(2 * b)
1348        .min(floor(n))
1349        .max(b);
1350        let keep = keep_target.min(floor(basis / 2)).max(b);
1351
1352        (b, basis, keep)
1353    }
1354}
1355
1356impl Default for BlockLanczosParams {
1357    fn default() -> Self {
1358        Self::new()
1359    }
1360}
1361
1362////////////////////////////
1363// Block Lanczos: helpers //
1364////////////////////////////
1365
1366/// Check that a block operator returned the `n x width` block it was asked for.
1367///
1368/// ### Params
1369///
1370/// * `out` - What the operator returned
1371/// * `n` - Dimension of the operator
1372/// * `width` - Columns of the block that was applied
1373///
1374/// ### Returns
1375///
1376/// `Ok(())`, or [CoreErrors::InvalidArgument] naming both shapes.
1377fn check_operator_output(out: &Mat<f64>, n: usize, width: usize) -> Result<(), CoreErrors> {
1378    if out.nrows() != n || out.ncols() != width {
1379        return Err(CoreErrors::InvalidArgument(format!(
1380            "block Lanczos: operator returned {} x {}, expected {n} x {width}",
1381            out.nrows(),
1382            out.ncols()
1383        )));
1384    }
1385    Ok(())
1386}
1387
1388/// Leading eigenpairs of a small operator, formed densely.
1389///
1390/// ### Params
1391///
1392/// * `n` - Dimension of the operator.
1393/// * `n_keep` - Eigenpairs to return, at most `n`.
1394/// * `b` - Columns per operator call.
1395/// * `op` - Symmetric operator, returns `A X` for an `n x w` block `X`.
1396///
1397/// ### Returns
1398///
1399/// [RitzPairs] with `n_keep` exact pairs, zero residual and zero restarts.
1400fn dense_eigenpairs<E, M>(n: usize, n_keep: usize, b: usize, op: &mut M) -> Result<RitzPairs, E>
1401where
1402    E: From<CoreErrors>,
1403    M: FnMut(MatRef<f64>) -> Result<Mat<f64>, E>,
1404{
1405    let mut a = Mat::<f64>::zeros(n, n);
1406    for col in (0..n).step_by(b) {
1407        let w = b.min(n - col);
1408        let unit = Mat::<f64>::from_fn(n, w, |i, c| if i == col + c { 1.0 } else { 0.0 });
1409        let out = op(unit.as_ref())?;
1410        check_operator_output(&out, n, w)?;
1411        a.subcols_mut(col, w).copy_from(&out);
1412    }
1413    // An f32 operator is only symmetric to round-off.
1414    let sym = Mat::<f64>::from_fn(n, n, |i, j| 0.5 * (a[(i, j)] + a[(j, i)]));
1415
1416    let (values, vectors) = symmetric_eigen_descending(sym.as_ref(), n)?;
1417    let flat: Vec<f64> = (0..n_keep)
1418        .flat_map(|c| vectors.col(c).iter().copied().collect::<Vec<_>>())
1419        .collect();
1420
1421    Ok(RitzPairs {
1422        values: values[..n_keep].to_vec(),
1423        vectors: flat,
1424        residual: 0.0,
1425        norm_estimate: values[0].abs().max(values[n - 1].abs()),
1426        converged: true,
1427        restarts: 0,
1428    })
1429}
1430
1431/// Euclidean norm of every column.
1432///
1433/// ### Params
1434///
1435/// * `x` - Matrix whose columns to measure.
1436///
1437/// ### Returns
1438///
1439/// One norm per column.
1440fn column_norms(x: MatRef<f64>) -> Vec<f64> {
1441    (0..x.ncols()).map(|c| x.col(c).norm_l2()).collect()
1442}
1443
1444/// Orthonormalise a block against itself, column by column.
1445///
1446/// Two classical Gram-Schmidt passes per column against the columns before it.
1447/// A column whose remainder falls below `threshold` is dependent: it gets a
1448/// zero diagonal in `R` and a random replacement, orthogonalised twice against
1449/// `basis` and the columns so far. Later columns are still projected onto the
1450/// replacement and the coefficients land in `R`, so `W = Q R` holds exactly
1451/// up to the dropped remainders, which is what keeps the Arnoldi relation
1452/// intact. A Householder QR cannot do this without pivoting: zeroing a row of
1453/// its `R` also drops the off-diagonal terms of every later column.
1454///
1455/// ### Params
1456///
1457/// * `w` - Block to orthonormalise, `n x b`, already orthogonal to `basis`.
1458/// * `basis` - Basis the replacements must stay orthogonal to.
1459/// * `threshold` - Remainder norm at or below which a column is dependent.
1460/// * `rng` - Source of the replacement vectors.
1461///
1462/// ### Returns
1463///
1464/// `(q, r, n_deficient)`: orthonormal `q` (`n x b`), upper triangular `r`
1465/// (`b x b`) and the number of dependent columns.
1466fn orthonormalise_block(
1467    w: MatRef<f64>,
1468    basis: MatRef<f64>,
1469    threshold: f64,
1470    rng: &mut StdRng,
1471) -> (Mat<f64>, Mat<f64>, usize) {
1472    let (n, b) = w.shape();
1473    let mut q = w.to_owned();
1474    let mut r = Mat::<f64>::zeros(b, b);
1475    let mut n_deficient = 0usize;
1476
1477    for c in 0..b {
1478        for _ in 0..2 {
1479            for i in 0..c {
1480                let d = q.col(i).transpose() * q.col(c);
1481                r[(i, c)] += d;
1482                for k in 0..n {
1483                    q[(k, c)] -= d * q[(k, i)];
1484                }
1485            }
1486        }
1487
1488        let remainder = q.col(c).norm_l2();
1489        if remainder > threshold {
1490            r[(c, c)] = remainder;
1491            let inv = 1.0 / remainder;
1492            q.col_mut(c).iter_mut().for_each(|x| *x *= inv);
1493            continue;
1494        }
1495
1496        n_deficient += 1;
1497        let mut x = Mat::<f64>::from_fn(n, 1, |_, _| rng.random::<f64>() - 0.5);
1498        for _ in 0..2 {
1499            let mut coeffs = Mat::<f64>::zeros(basis.ncols(), 1);
1500            gemm(
1501                coeffs.as_mut(),
1502                Accum::Replace,
1503                basis.transpose(),
1504                x.as_ref(),
1505                1.0,
1506                Par::Seq,
1507            );
1508            gemm(
1509                x.as_mut(),
1510                Accum::Add,
1511                basis,
1512                coeffs.as_ref(),
1513                -1.0,
1514                Par::Seq,
1515            );
1516            for i in 0..c {
1517                let d = q.col(i).transpose() * x.col(0);
1518                for k in 0..n {
1519                    x[(k, 0)] -= d * q[(k, i)];
1520                }
1521            }
1522        }
1523        let scale = x.col(0).norm_l2();
1524        for k in 0..n {
1525            q[(k, c)] = x[(k, 0)] / scale;
1526        }
1527    }
1528
1529    (q, r, n_deficient)
1530}
1531
1532/////////////////////////////////////
1533// Block Lanczos: public functions //
1534/////////////////////////////////////
1535
1536/// Largest eigenpairs of a symmetric operator via block thick-restarted
1537/// Lanczos.
1538///
1539/// The block twin of [lanczos_thick_restart], with the same structure: an
1540/// explicit projection `H = V^T A V` (arrow-shaped after a restart),
1541/// classical Gram-Schmidt with a DGKS-conditional second pass, Rayleigh-Ritz,
1542/// and a thick restart that keeps the best Ritz vectors plus the residual
1543/// block. The operator is applied to a whole block per call, which is the
1544/// point: on a device every call carries a fixed cost, and a block of `b`
1545/// vectors pays it once instead of `b` times.
1546///
1547/// New blocks are orthonormalised among themselves by a Householder QR. If
1548/// that block is rank deficient, the dependent directions are replaced by
1549/// random vectors orthogonal to everything so far; the dropped residual is
1550/// below the breakdown threshold, so the Arnoldi relation
1551/// `A V = V H + Q R E^T` survives. A block that vanishes entirely means the
1552/// basis spans an invariant subspace and the Ritz pairs are exact.
1553///
1554/// Convergence is the exact block residual `||R y_last||`, with `y_last` the
1555/// last block of the Ritz coefficient vector, against `tol * ||A||`.
1556///
1557/// When the basis plus one block would reach the operator dimension, the
1558/// residual block has no room left to stay orthogonal to the basis. The
1559/// operator is then formed densely, a block of unit vectors per call, and
1560/// decomposed exactly; that costs no more calls than a single cycle.
1561///
1562/// Internals are f64 regardless of the operator's precision.
1563///
1564/// ### Params
1565///
1566/// * `n` - Dimension of the operator.
1567/// * `n_components` - Number of eigenpairs to compute. Capped at `n`.
1568/// * `seed` - Seed for the starting block.
1569/// * `params` - [BlockLanczosParams].
1570/// * `op` - Symmetric operator, returns `A X` for an `n x b` block `X`.
1571///
1572/// ### Returns
1573///
1574/// [RitzPairs] with at most `min(n_components, n)` pairs, fewer if the
1575/// iteration hit an invariant subspace first.
1576pub fn block_lanczos_thick_restart<E, M>(
1577    n: usize,
1578    n_components: usize,
1579    seed: u64,
1580    params: BlockLanczosParams,
1581    mut op: M,
1582) -> Result<RitzPairs, E>
1583where
1584    E: From<CoreErrors>,
1585    M: FnMut(MatRef<f64>) -> Result<Mat<f64>, E>,
1586{
1587    if n_components == 0 || n == 0 {
1588        return Err(CoreErrors::MustBePositive(
1589            "n_components and the operator dimension".to_string(),
1590        )
1591        .into());
1592    }
1593
1594    let n_keep = n_components.min(n);
1595    let (b, m, keep) = params.resolve(n_keep, n);
1596    let par = faer_parallelism();
1597
1598    if m + b > n {
1599        return dense_eigenpairs(n, n_keep, b, &mut op);
1600    }
1601
1602    let mut rng = StdRng::seed_from_u64(seed);
1603    let mut basis = Mat::<f64>::zeros(n, m);
1604    let mut h = Mat::<f64>::zeros(m, m);
1605    let start = Mat::<f64>::from_fn(n, b, |_, _| rng.random::<f64>() - 0.5);
1606    basis
1607        .subcols_mut(0, b)
1608        .copy_from(start.qr().compute_thin_Q());
1609
1610    // Residual block of the last cycle, `A V = V H + resid_q resid_r E^T`.
1611    let mut resid_q = Mat::<f64>::zeros(n, b);
1612    let mut resid_r = Mat::<f64>::zeros(b, b);
1613
1614    let mut locked = 0usize;
1615    let mut ritz_values: Vec<f64> = Vec::new();
1616    let mut ritz_coeffs = Mat::<f64>::zeros(0, 0);
1617    let mut m_final = 0usize;
1618
1619    let mut norm_estimate = 0.0f64;
1620    let mut residual = f64::INFINITY;
1621    let mut converged = false;
1622    let mut restarts = 0usize;
1623
1624    let budget = params.max_restarts.max(1);
1625
1626    for cycle in 0..budget {
1627        restarts = cycle + 1;
1628        let mut m_eff = m;
1629        let mut invariant = false;
1630
1631        let mut j = locked;
1632        while j < m {
1633            let width = j + b;
1634            let mut w = op(basis.subcols(j, b))?;
1635            check_operator_output(&w, n, b)?;
1636
1637            // Rayleigh-Ritz coefficients against the basis so far, taken before
1638            // any subtraction so `h` is the exact projection.
1639            let v = basis.subcols(0, width);
1640            let mut coeffs = Mat::<f64>::zeros(width, b);
1641            gemm(
1642                coeffs.as_mut(),
1643                Accum::Replace,
1644                v.transpose(),
1645                w.as_ref(),
1646                1.0,
1647                par,
1648            );
1649            for c in 0..b {
1650                for i in 0..width {
1651                    h[(i, j + c)] = coeffs[(i, c)];
1652                    h[(j + c, i)] = coeffs[(i, c)];
1653                }
1654            }
1655
1656            let before = column_norms(w.as_ref());
1657            norm_estimate = before.iter().fold(norm_estimate, |acc, &x| acc.max(x));
1658
1659            gemm(w.as_mut(), Accum::Add, v, coeffs.as_ref(), -1.0, par);
1660            let after = column_norms(w.as_ref());
1661
1662            if after
1663                .iter()
1664                .zip(&before)
1665                .any(|(&a, &bf)| a < LANCZOS_ORTHO_REFINE_RATIO * bf)
1666            {
1667                gemm(
1668                    coeffs.as_mut(),
1669                    Accum::Replace,
1670                    v.transpose(),
1671                    w.as_ref(),
1672                    1.0,
1673                    par,
1674                );
1675                gemm(w.as_mut(), Accum::Add, v, coeffs.as_ref(), -1.0, par);
1676            }
1677
1678            let threshold = LANCZOS_BREAKDOWN_RATIO * norm_estimate;
1679            let (q, r, n_deficient) = orthonormalise_block(w.as_ref(), v, threshold, &mut rng);
1680
1681            if n_deficient == b {
1682                m_eff = width;
1683                invariant = true;
1684                break;
1685            }
1686
1687            if width < m {
1688                basis.subcols_mut(width, b).copy_from(&q);
1689            } else {
1690                resid_q = q;
1691                resid_r = r;
1692            }
1693            j = width;
1694        }
1695
1696        let (values, vectors) = symmetric_eigen_descending(h.as_ref(), m_eff)?;
1697
1698        let n_out = n_keep.min(m_eff);
1699        residual = if invariant {
1700            0.0
1701        } else {
1702            let mut y_last = Mat::<f64>::zeros(b, n_out);
1703            y_last.copy_from(vectors.submatrix(m_eff - b, 0, b, n_out));
1704            let mut ry = Mat::<f64>::zeros(b, n_out);
1705            gemm(
1706                ry.as_mut(),
1707                Accum::Replace,
1708                resid_r.as_ref(),
1709                y_last.as_ref(),
1710                1.0,
1711                Par::Seq,
1712            );
1713            column_norms(ry.as_ref()).into_iter().fold(0.0f64, f64::max)
1714        };
1715
1716        norm_estimate = norm_estimate
1717            .max(values[0].abs())
1718            .max(values[m_eff - 1].abs());
1719        converged = residual <= params.tol * norm_estimate;
1720
1721        ritz_values = values;
1722        ritz_coeffs = vectors;
1723        m_final = m_eff;
1724
1725        if converged || invariant || cycle + 1 == budget {
1726            break;
1727        }
1728
1729        // Thick restart: best `keep` Ritz vectors, then the residual block.
1730        let mut restarted = Mat::<f64>::zeros(n, keep);
1731        gemm(
1732            restarted.as_mut(),
1733            Accum::Replace,
1734            basis.subcols(0, m_eff),
1735            ritz_coeffs.subcols(0, keep),
1736            1.0,
1737            par,
1738        );
1739
1740        h.fill(0.0);
1741        basis.subcols_mut(0, keep).copy_from(&restarted);
1742        for (i, &value) in ritz_values.iter().take(keep).enumerate() {
1743            h[(i, i)] = value;
1744        }
1745        basis.subcols_mut(keep, b).copy_from(&resid_q);
1746        locked = keep;
1747    }
1748
1749    let n_out = n_keep.min(m_final);
1750    let mut ritz = Mat::<f64>::zeros(n, n_out);
1751    gemm(
1752        ritz.as_mut(),
1753        Accum::Replace,
1754        basis.subcols(0, m_final),
1755        ritz_coeffs.subcols(0, n_out),
1756        1.0,
1757        par,
1758    );
1759
1760    let mut vectors = vec![0.0f64; n * n_out];
1761    for (c, column) in vectors.chunks_exact_mut(n).enumerate() {
1762        let src = ritz.col(c);
1763        let scale = src.norm_l2();
1764        let inv = if scale > 0.0 { 1.0 / scale } else { 0.0 };
1765        for (dst, &v) in column.iter_mut().zip(src.iter()) {
1766            *dst = v * inv;
1767        }
1768    }
1769
1770    Ok(RitzPairs {
1771        values: ritz_values[..n_out].to_vec(),
1772        vectors,
1773        residual,
1774        norm_estimate,
1775        converged,
1776        restarts,
1777    })
1778}
1779
1780/////////////////
1781// Lanczos SVD //
1782/////////////////
1783
1784///////////////////////////////////
1785// Lanczos SVD: public functions //
1786///////////////////////////////////
1787
1788/// Compute sparse SVD using Lanczos on A^T A or AA^T
1789///
1790/// Thick-restarted with the default [LanczosParams], so the trailing
1791/// components converge on a flat spectrum too.
1792///
1793/// ### Params
1794///
1795/// * `matrix` - Sparse matrix (CSR or CSC)
1796/// * `n_components` - Number of singular values/vectors to compute
1797/// * `seed` - For reproducibility
1798/// * `use_second_layer` - If true, use data_2 instead of data
1799/// * `col_means` - Optional column means for implicit mean centering
1800/// * `col_stds` - Optional column sds for implicit variance normalising
1801/// * `row_offsets` - Additional offsets (for example for CLR-type PCA in single
1802///   cell).
1803///
1804/// ### Returns
1805///
1806/// `SvdResults` containing U (n×k), S (length k), and V (m×k)
1807pub fn sparse_svd_lanczos<T, U, F>(
1808    matrix: &CompressedSparseData2<T, U>,
1809    n_components: usize,
1810    seed: u64,
1811    use_second_layer: bool,
1812    col_means: Option<&[F]>,
1813    col_stds: Option<&[F]>,
1814    row_offsets: Option<&[F]>,
1815) -> Result<SvdResults<F>, CoreErrors>
1816where
1817    T: BixverseNumeric + BixverseSimd + Into<F> + Clone,
1818    U: BixverseNumeric + Into<F> + Clone,
1819    F: BixverseFloat + BixverseSimd + std::iter::Sum,
1820{
1821    let (n, m) = matrix.shape;
1822    let use_ata = n > m;
1823    let krylov_dim = if use_ata { m } else { n };
1824
1825    // keep both representations to make matvec operations fast
1826    let (csr, csc);
1827    let csr_owned;
1828    let csc_owned;
1829
1830    match matrix.cs_type {
1831        CompressedSparseFormat::Csr => {
1832            csr = matrix;
1833            csc_owned = matrix.transform_single_layer(use_second_layer)?;
1834            csc = &csc_owned;
1835        }
1836        CompressedSparseFormat::Csc => {
1837            csc = matrix;
1838            csr_owned = matrix.transform_single_layer(use_second_layer)?;
1839            csr = &csr_owned;
1840        }
1841    };
1842
1843    // helper to extract the right data layer and cast to F
1844    let extract_data = |mat: &CompressedSparseData2<T, U>| -> Vec<F> {
1845        if use_second_layer {
1846            mat.data_2
1847                .as_ref()
1848                .expect("data_2 is None but use_second_layer is true")
1849                .iter()
1850                .copied()
1851                .map(|v| v.into())
1852                .collect()
1853        } else {
1854            mat.data.iter().copied().map(|v| v.into()).collect()
1855        }
1856    };
1857
1858    let data_csr_f = extract_data(csr);
1859    let data_csc_f = extract_data(csc);
1860
1861    // matrix-vector product for A (using CSR)
1862    let matvec_a = |x: &[F], y: &mut [F]| {
1863        let x_scaled: Vec<F> = if let Some(sd) = col_stds {
1864            x.iter().enumerate().map(|(j, &v)| v / sd[j]).collect()
1865        } else {
1866            x.to_vec()
1867        };
1868        let mean_dot: F = if let Some(mu) = col_means {
1869            x_scaled.iter().enumerate().map(|(j, &v)| mu[j] * v).sum()
1870        } else {
1871            F::zero()
1872        };
1873        let x_sum: F = if row_offsets.is_some() {
1874            x_scaled.iter().copied().sum()
1875        } else {
1876            F::zero()
1877        };
1878
1879        y.par_iter_mut().enumerate().for_each(|(i, yi)| {
1880            let mut sum = F::zero();
1881            for idx in csr.indptr[i] as usize..csr.indptr[i + 1] as usize {
1882                let j = csr.indices[idx] as usize;
1883                sum += data_csr_f[idx] * x_scaled[j];
1884            }
1885            if col_means.is_some() {
1886                sum -= mean_dot;
1887            }
1888            if let Some(off) = row_offsets {
1889                sum -= off[i] * x_sum;
1890            }
1891            *yi = sum;
1892        });
1893    };
1894
1895    let matvec_at = |x: &[F], y: &mut [F]| {
1896        let x_sum: F = x.iter().copied().sum();
1897        let m_dot_x: F = if let Some(off) = row_offsets {
1898            off.iter().zip(x.iter()).map(|(&m_i, &xi)| m_i * xi).sum()
1899        } else {
1900            F::zero()
1901        };
1902
1903        y.par_iter_mut().enumerate().for_each(|(j, yj)| {
1904            let mut sum = F::zero();
1905            for idx in csc.indptr[j] as usize..csc.indptr[j + 1] as usize {
1906                let i = csc.indices[idx] as usize;
1907                sum += data_csc_f[idx] * x[i];
1908            }
1909            if let Some(mu) = col_means {
1910                sum -= mu[j] * x_sum;
1911            }
1912            if row_offsets.is_some() {
1913                sum -= m_dot_x;
1914            }
1915            if let Some(sd) = col_stds {
1916                sum /= sd[j];
1917            }
1918            *yj = sum;
1919        });
1920    };
1921
1922    // select Gram matrix operator
1923    #[allow(clippy::type_complexity)]
1924    let matvec_gram: Box<dyn Fn(&[F], &mut [F]) + Sync> = if use_ata {
1925        Box::new(|x: &[F], y: &mut [F]| {
1926            let mut temp = vec![F::zero(); n];
1927            matvec_a(x, &mut temp);
1928            matvec_at(&temp, y);
1929        })
1930    } else {
1931        Box::new(|x: &[F], y: &mut [F]| {
1932            let mut temp = vec![F::zero(); m];
1933            matvec_at(x, &mut temp);
1934            matvec_a(&temp, y);
1935        })
1936    };
1937
1938    // the restarted solver runs in f64; the Gram operator stays in F
1939    let matvec_gram_f64 = |x: &[f64], y: &mut [f64]| {
1940        let x_f: Vec<F> = x
1941            .iter()
1942            .map(|&v| F::from(v).expect("f64 casts into any float type"))
1943            .collect();
1944        let mut y_f = vec![F::zero(); krylov_dim];
1945        matvec_gram(&x_f, &mut y_f);
1946        for (dst, src) in y.iter_mut().zip(y_f) {
1947            *dst = src.to_f64().expect("float casts into f64");
1948        }
1949    };
1950
1951    let ritz = lanczos_thick_restart(
1952        krylov_dim,
1953        n_components,
1954        seed,
1955        LanczosParams::default(),
1956        matvec_gram_f64,
1957    )?;
1958
1959    let mut singular_values: Vec<F> = Vec::with_capacity(n_components);
1960    let mut u_vecs: Vec<Vec<F>> = Vec::with_capacity(n_components);
1961    let mut v_vecs: Vec<Vec<F>> = Vec::with_capacity(n_components);
1962
1963    for (idx, &eval) in ritz.values.iter().enumerate() {
1964        if eval <= 0.0 {
1965            continue;
1966        }
1967
1968        let sigma = F::from(eval.sqrt()).expect("f64 casts into any float type");
1969        singular_values.push(sigma);
1970
1971        let gram_evec: Vec<F> = ritz.vectors[idx * krylov_dim..(idx + 1) * krylov_dim]
1972            .iter()
1973            .map(|&v| F::from(v).expect("f64 casts into any float type"))
1974            .collect();
1975
1976        if use_ata {
1977            let mut u_vec = vec![F::zero(); n];
1978            matvec_a(&gram_evec, &mut u_vec);
1979            for x in &mut u_vec {
1980                *x /= sigma;
1981            }
1982            u_vecs.push(u_vec);
1983            v_vecs.push(gram_evec);
1984        } else {
1985            let mut v_vec = vec![F::zero(); m];
1986            matvec_at(&gram_evec, &mut v_vec);
1987            for x in &mut v_vec {
1988                *x /= sigma;
1989            }
1990            u_vecs.push(gram_evec);
1991            v_vecs.push(v_vec);
1992        }
1993    }
1994
1995    let u = Mat::from_fn(n, singular_values.len(), |i, j| u_vecs[j][i]);
1996    let v = Mat::from_fn(m, singular_values.len(), |i, j| v_vecs[j][i]);
1997
1998    Ok(SvdResults {
1999        u,
2000        s: singular_values,
2001        v,
2002    })
2003}
2004
2005///////////
2006// Tests //
2007///////////
2008
2009#[cfg(test)]
2010mod tests {
2011    use super::*;
2012    use crate::core::math::vector_helpers::rank_vector;
2013    use faer::Mat;
2014
2015    /// The zero counts and the dense-to-CSR conversion agree on what is structurally zero.
2016    #[test]
2017    fn test_from_dense_and_count_zeroes() {
2018        let mat: Mat<f64> = Mat::from_fn(3, 2, |i, j| if i == j { (i + 1) as f64 } else { 0.0 });
2019        let (total_zeroes, row_zeroes, col_zeroes) = count_zeroes(&mat.as_ref());
2020
2021        assert_eq!(total_zeroes, 4);
2022        assert_eq!(row_zeroes, vec![1, 1, 2]);
2023        assert_eq!(col_zeroes, vec![2, 2]);
2024
2025        let csr = CompressedSparseData2::<f64, f64>::from_dense_matrix(
2026            mat.as_ref(),
2027            CompressedSparseFormat::Csr,
2028        );
2029        assert_eq!(csr.shape, (3, 2));
2030        assert_eq!(csr.data, vec![1.0, 2.0]);
2031    }
2032
2033    /// Lanczos recovers the one nonzero eigenpair of a symmetric rank-one matrix.
2034    #[test]
2035    fn test_lanczos_eigenpairs_logic() {
2036        // Symmetric rank-1 matrix M = x * x^T
2037        // Let x = [1.0, 0.0, 2.0, 0.0]^T
2038        let data = vec![1.0, 2.0, 2.0, 4.0];
2039        let indices = vec![0, 2, 0, 2];
2040        let indptr = vec![0, 2, 2, 4, 4]; // Rows 1 and 3 are empty
2041        let shape = (4, 4);
2042
2043        let csr = CompressedSparseData2::<f64, f64>::new_csr(&data, &indices, &indptr, None, shape);
2044
2045        // Lanczos expects symmetric matrix, this one is symmetric
2046        let (evals, evecs) = compute_largest_eigenpairs_lanczos(&csr, 1, 42, None).unwrap();
2047
2048        // True top eigenvalue should be exactly sum(x_i^2) = 1.0 + 4.0 = 5.0
2049        assert!((evals[0] - 5.0).abs() < 1e-3);
2050
2051        // Eigenvectors are returned transposed: evecs[point_idx][comp_idx]
2052        // So the first principal component is [evecs[0][0], evecs[1][0], evecs[2][0], evecs[3][0]]
2053        let x_norm = 5.0_f32.sqrt();
2054        let dot_x = (evecs[0][0] * 1.0 + evecs[2][0] * 2.0) / x_norm;
2055
2056        assert!(dot_x.abs() > 0.999);
2057    }
2058
2059    /// Lanczos SVD recovers both singular vectors of a sparse rank-one matrix.
2060    #[test]
2061    fn test_sparse_svd_lanczos_logic() {
2062        // Sparse rank-1 matrix A = x * y^T
2063        // x = [0.0, 2.0, 0.0, 4.0]^T
2064        // y = [1.0, 0.0, 0.5]^T
2065        let data = vec![2.0, 1.0, 4.0, 2.0];
2066        let indices = vec![0, 2, 0, 2];
2067        let indptr = vec![0, 0, 2, 2, 4];
2068        let shape = (4, 3);
2069
2070        let csr = CompressedSparseData2::<f64, f64>::new_csr(&data, &indices, &indptr, None, shape);
2071        let no_params: Option<&[f64]> = None;
2072
2073        let svd = sparse_svd_lanczos(&csr, 1, 42, false, no_params, no_params, None).unwrap();
2074
2075        // Test correlation with theoretical U
2076        let u_col = svd.u.col(0);
2077        let x_norm = 20.0_f64.sqrt();
2078        let dot_u = (u_col[1] * 2.0 + u_col[3] * 4.0) / x_norm;
2079        assert!(dot_u.abs() > 0.999);
2080
2081        // Test correlation with theoretical V
2082        let v_col = svd.v.col(0);
2083        let y_norm = 1.25_f64.sqrt();
2084        let dot_v = (v_col[0] * 1.0 + v_col[2] * 0.5) / y_norm;
2085        assert!(dot_v.abs() > 0.999);
2086    }
2087
2088    /// Thirty components of a centred uniform noise matrix match a dense SVD.
2089    ///
2090    /// The spectrum is flat past the first component, which is where a single
2091    /// unrestarted Lanczos pass leaves the trailing pairs unconverged.
2092    #[test]
2093    fn test_sparse_svd_lanczos_flat_spectrum() {
2094        let (n, m, k) = (400usize, 200usize, 30usize);
2095        let mut rng = StdRng::seed_from_u64(1);
2096        let vals: Vec<f64> = (0..n * m).map(|_| rng.random::<f64>()).collect();
2097        let indices: Vec<u32> = (0..n).flat_map(|_| 0..m as u32).collect();
2098        let indptr: Vec<u32> = (0..=n).map(|i| (i * m) as u32).collect();
2099        let csr =
2100            CompressedSparseData2::<f64, f64>::new_csr(&vals, &indices, &indptr, None, (n, m));
2101
2102        let means: Vec<f64> = (0..m)
2103            .map(|j| (0..n).map(|i| vals[i * m + j]).sum::<f64>() / n as f64)
2104            .collect();
2105        let svd = sparse_svd_lanczos(&csr, k, 42, false, Some(&means[..]), None, None).unwrap();
2106
2107        let centred = Mat::<f64>::from_fn(n, m, |i, j| vals[i * m + j] - means[j]);
2108        let reference = centred.thin_svd().unwrap();
2109        let s_ref = reference.S().column_vector();
2110
2111        assert_eq!(svd.s.len(), k);
2112        for i in 0..k {
2113            assert_relative_eq!(svd.s[i], s_ref[i], max_relative = 1e-6);
2114        }
2115    }
2116
2117    use approx::assert_relative_eq;
2118
2119    /// Adjacency matrix of a path graph on `n` nodes.
2120    ///
2121    /// Eigenvalues are `2 cos(k pi / (n + 1))`, separated by `O(1 / n^2)`, which
2122    /// makes it the cheapest genuinely clustered spectrum to build.
2123    fn path_graph(n: usize) -> CompressedSparseData2<f64, f64> {
2124        let mut rows: Vec<Vec<(u32, f64)>> = vec![Vec::new(); n];
2125        for i in 0..n - 1 {
2126            rows[i].push(((i + 1) as u32, 1.0));
2127            rows[i + 1].push((i as u32, 1.0));
2128        }
2129        let mut indptr = vec![0u32];
2130        let mut indices = Vec::new();
2131        let mut data = Vec::new();
2132        for row in rows.iter_mut() {
2133            row.sort_by_key(|&(j, _)| j);
2134            for &(j, v) in row.iter() {
2135                indices.push(j);
2136                data.push(v);
2137            }
2138            indptr.push(indices.len() as u32);
2139        }
2140
2141        CompressedSparseData2::new_csr(&data, &indices, &indptr, None, (n, n))
2142    }
2143
2144    /// Diagonal matrix in CSR, storing every entry given, zeros included.
2145    fn diagonal(diag: &[f64]) -> CompressedSparseData2<f64, f64> {
2146        let indices: Vec<u32> = (0..diag.len() as u32).collect();
2147        let indptr: Vec<u32> = (0..=diag.len() as u32).collect();
2148
2149        CompressedSparseData2::new_csr(diag, &indices, &indptr, None, (diag.len(), diag.len()))
2150    }
2151
2152    /// `||A x - lambda x||` for the `k`-th returned pair, with `vectors` indexed `[i][k]`.
2153    fn pair_residual(
2154        mat: &CompressedSparseData2<f64, f64>,
2155        values: &[f64],
2156        vectors: &[Vec<f32>],
2157        k: usize,
2158    ) -> f64 {
2159        let n = mat.shape.0;
2160        let v: Vec<f64> = (0..n).map(|i| vectors[i][k] as f64).collect();
2161        let lam = values[k];
2162
2163        (0..n)
2164            .map(|i| {
2165                let mut sum = 0.0;
2166                for x in mat.indptr[i] as usize..mat.indptr[i + 1] as usize {
2167                    sum += mat.data[x] * v[mat.indices[x] as usize];
2168                }
2169                (sum - lam * v[i]).powi(2)
2170            })
2171            .sum::<f64>()
2172            .sqrt()
2173    }
2174
2175    /// Regression: a tightly clustered spectrum used to come back as noise.
2176    #[test]
2177    fn test_lanczos_resolves_a_clustered_spectrum_at_defaults() {
2178        let n = 300usize;
2179        let mat = path_graph(n);
2180
2181        let (values, vectors) = compute_largest_eigenpairs_lanczos(&mat, 4, 42, None).unwrap();
2182
2183        for k in 0..4 {
2184            let expected = 2.0 * ((k + 1) as f64 * std::f64::consts::PI / (n + 1) as f64).cos();
2185            assert_relative_eq!(values[k], expected, epsilon = 1e-3);
2186        }
2187
2188        // The leading eigenvector is sin(pi x / (n + 1)), so it has no sign
2189        // change anywhere. Noise does.
2190        let sign = vectors[0][0].signum();
2191        assert!(
2192            (0..n).all(|i| vectors[i][0].signum() == sign || vectors[i][0] == 0.0),
2193            "leading eigenvector of a path graph changed sign"
2194        );
2195    }
2196
2197    /// Given a big enough restart budget the same clustered spectrum does converge.
2198    #[test]
2199    fn test_lanczos_restarts_converge_a_clustered_spectrum() {
2200        // Same fixture with a budget large enough to actually converge, which
2201        // is what the restart machinery exists for. 300 nodes, ~40 cycles.
2202        let n = 300usize;
2203        let mat = path_graph(n);
2204        let params = LanczosParams {
2205            basis_size: None,
2206            max_restarts: 64,
2207            tol: 1e-8,
2208        };
2209
2210        let res = compute_largest_eigenpairs_lanczos_diag(&mat, 4, 42, Some(params)).unwrap();
2211
2212        assert!(res.converged, "residual stalled at {:e}", res.residual);
2213        for k in 0..4 {
2214            let expected = 2.0 * ((k + 1) as f64 * std::f64::consts::PI / (n + 1) as f64).cos();
2215            assert_relative_eq!(res.eigenvalues[k], expected, epsilon = 1e-6);
2216
2217            let residual = pair_residual(&mat, &res.eigenvalues, &res.eigenvectors, k);
2218            assert!(residual < 1e-5, "pair {k} has residual {residual:e}");
2219        }
2220    }
2221
2222    /// Regression: asking for more components than the matrix has rows used to panic.
2223    #[test]
2224    fn test_lanczos_caps_components_at_the_matrix_dimension() {
2225        // Used to panic inside `clamp` before it ever got to the iteration.
2226        let mat = diagonal(&[4.0, 3.0, 2.0, 1.0]);
2227
2228        for requested in [4usize, 5, 40] {
2229            let (values, vectors) =
2230                compute_largest_eigenpairs_lanczos(&mat, requested, 11, None).unwrap();
2231
2232            assert_eq!(values.len(), 4, "requested {requested}");
2233            assert_eq!(vectors[0].len(), values.len(), "requested {requested}");
2234            assert_relative_eq!(values[0], 4.0, epsilon = 1e-9);
2235            assert_relative_eq!(values[3], 1.0, epsilon = 1e-9);
2236        }
2237    }
2238
2239    /// Regression: an early Krylov breakdown used to leave values and vectors different lengths.
2240    #[test]
2241    fn test_lanczos_breakdown_keeps_values_and_vectors_the_same_length() {
2242        // Rank two, so the Krylov space closes after three vectors and there is
2243        // no way to produce the ten pairs asked for. Both outputs must agree on
2244        // how many there are; they used to disagree and index out of bounds
2245        // downstream.
2246        let mut diag = vec![0.0f64; 12];
2247        diag[0] = 5.0;
2248        diag[1] = 3.0;
2249        let mat = diagonal(&diag);
2250
2251        let res = compute_largest_eigenpairs_lanczos_diag(&mat, 10, 3, None).unwrap();
2252
2253        assert!(res.eigenvalues.len() < 10, "expected an early breakdown");
2254        assert_eq!(res.eigenvalues.len(), res.eigenvectors[0].len());
2255        assert_relative_eq!(res.eigenvalues[0], 5.0, epsilon = 1e-9);
2256        assert_relative_eq!(res.eigenvalues[1], 3.0, epsilon = 1e-9);
2257    }
2258
2259    /// A solve that ran out of restarts has to report itself as unconverged.
2260    #[test]
2261    fn test_lanczos_reports_budget_exhaustion() {
2262        // One cycle on a path graph cannot converge, and the caller has to be
2263        // able to tell that apart from a solve that did.
2264        let mat = path_graph(300);
2265        let params = LanczosParams {
2266            basis_size: None,
2267            max_restarts: 1,
2268            tol: 1e-8,
2269        };
2270
2271        let res = compute_largest_eigenpairs_lanczos_diag(&mat, 4, 42, Some(params)).unwrap();
2272
2273        assert!(!res.converged);
2274        assert_eq!(res.restarts, 1);
2275        assert!(res.residual > params.tol * res.norm_estimate);
2276    }
2277
2278    /// Scaling the whole matrix must not change whether the solve converges.
2279    #[test]
2280    fn test_lanczos_tolerance_is_relative_to_the_matrix_norm() {
2281        // Scaling the matrix by 1e8 scales every residual by 1e8 too. An
2282        // absolute tolerance would make the scaled problem unconvergeable; a
2283        // relative one converges both in the same number of cycles.
2284        let small = diagonal(&[4.0, 3.0, 2.0, 1.0]);
2285        let large = diagonal(&[4e8, 3e8, 2e8, 1e8]);
2286
2287        let a = compute_largest_eigenpairs_lanczos_diag(&small, 2, 5, None).unwrap();
2288        let b = compute_largest_eigenpairs_lanczos_diag(&large, 2, 5, None).unwrap();
2289
2290        assert!(a.converged && b.converged);
2291        assert_relative_eq!(b.eigenvalues[0] / a.eigenvalues[0], 1e8, epsilon = 1e-1);
2292    }
2293
2294    /// A basis smaller than the default still resolves the top eigenvalues via restarts.
2295    #[test]
2296    fn test_lanczos_params_basis_size_is_honoured() {
2297        let n = 40usize;
2298        let mut indptr = vec![0u32];
2299        let mut indices = Vec::new();
2300        let mut data = Vec::new();
2301        for i in 0..n {
2302            indices.push(i as u32);
2303            data.push((i + 1) as f64);
2304            indptr.push(indices.len() as u32);
2305        }
2306        let mat = CompressedSparseData2::new_csr(&data, &indices, &indptr, None, (n, n));
2307
2308        // Diagonal matrix: the largest eigenvalue is n regardless of the basis.
2309        let tight = LanczosParams {
2310            basis_size: Some(6),
2311            max_restarts: 200,
2312            tol: 1e-10,
2313        };
2314        let (values, _) = compute_largest_eigenpairs_lanczos(&mat, 3, 7, Some(tight)).unwrap();
2315
2316        assert_relative_eq!(values[0], 40.0, epsilon = 1e-8);
2317        assert_relative_eq!(values[1], 39.0, epsilon = 1e-8);
2318        assert_relative_eq!(values[2], 38.0, epsilon = 1e-8);
2319    }
2320
2321    ///////////////////
2322    // Block Lanczos //
2323    ///////////////////
2324
2325    /// Block operator over a symmetric CSR, as the GPU drivers present it.
2326    fn block_op(
2327        mat: &CompressedSparseData2<f64, f64>,
2328        x: MatRef<f64>,
2329    ) -> Result<Mat<f64>, CoreErrors> {
2330        let n = mat.shape.0;
2331        Ok(Mat::from_fn(n, x.ncols(), |i, c| {
2332            (mat.indptr[i] as usize..mat.indptr[i + 1] as usize)
2333                .map(|k| mat.data[k] * x[(mat.indices[k] as usize, c)])
2334                .sum()
2335        }))
2336    }
2337
2338    /// Block Lanczos converges a clustered spectrum to the same values as the scalar solver.
2339    #[test]
2340    fn test_block_lanczos_matches_scalar_on_a_clustered_spectrum() {
2341        let n = 300usize;
2342        let mat = path_graph(n);
2343        let scalar = compute_largest_eigenpairs_lanczos_diag(&mat, 4, 42, None).unwrap();
2344
2345        // Wider blocks need more restarts here: at a fixed basis they buy a
2346        // shallower Krylov space per cycle, and this spectrum is clustered.
2347        for block_size in [1usize, 4, 16] {
2348            let params = BlockLanczosParams {
2349                block_size,
2350                basis_size: None,
2351                max_restarts: 1000,
2352                tol: 1e-8,
2353            };
2354            let res = block_lanczos_thick_restart(n, 4, 42, params, |x| block_op(&mat, x)).unwrap();
2355
2356            assert!(
2357                res.converged,
2358                "b = {block_size}: residual {:e}",
2359                res.residual
2360            );
2361            for k in 0..4 {
2362                assert_relative_eq!(res.values[k], scalar.eigenvalues[k], epsilon = 1e-7);
2363                let v: Vec<Vec<f32>> = (0..n)
2364                    .map(|i| vec![res.vectors[k * n + i] as f32])
2365                    .collect();
2366                let residual = pair_residual(&mat, &res.values[k..k + 1], &v, 0);
2367                assert!(residual < 1e-5, "b = {block_size}, pair {k}: {residual:e}");
2368            }
2369        }
2370    }
2371
2372    /// A low-rank operator closes the Krylov space mid-block; the dependent directions are replaced.
2373    #[test]
2374    fn test_block_lanczos_low_rank_recovers_the_nonzero_pairs() {
2375        // Rank two with a block of four: the first residual block is rank
2376        // deficient, so the replacement path runs before the space closes.
2377        let mut diag = vec![0.0f64; 200];
2378        diag[0] = 5.0;
2379        diag[1] = 3.0;
2380        let mat = diagonal(&diag);
2381        let params = BlockLanczosParams {
2382            block_size: 4,
2383            ..BlockLanczosParams::new()
2384        };
2385
2386        let res = block_lanczos_thick_restart(200, 2, 3, params, |x| block_op(&mat, x)).unwrap();
2387
2388        assert!(res.converged);
2389        assert_relative_eq!(res.values[0], 5.0, epsilon = 1e-9);
2390        assert_relative_eq!(res.values[1], 3.0, epsilon = 1e-9);
2391        assert_relative_eq!(res.vectors[0].abs(), 1.0, epsilon = 1e-6);
2392        assert_relative_eq!(res.vectors[200 + 1].abs(), 1.0, epsilon = 1e-6);
2393    }
2394
2395    /// An operator too small for Lanczos is solved densely, every pair coming back.
2396    #[test]
2397    fn test_block_lanczos_caps_the_block_at_the_dimension() {
2398        let mat = diagonal(&[4.0, 3.0, 2.0, 1.0]);
2399        let params = BlockLanczosParams {
2400            block_size: 64,
2401            ..BlockLanczosParams::new()
2402        };
2403
2404        let res = block_lanczos_thick_restart(4, 10, 1, params, |x| block_op(&mat, x)).unwrap();
2405
2406        assert_eq!(res.values.len(), 4);
2407        assert_eq!(res.vectors.len(), 16);
2408        assert_relative_eq!(res.values[0], 4.0, epsilon = 1e-9);
2409        assert_relative_eq!(res.values[3], 1.0, epsilon = 1e-9);
2410    }
2411
2412    /// Regression: a basis one block short of the dimension used to fill the residual block with noise.
2413    #[test]
2414    fn test_block_lanczos_ragged_dimension_is_exact() {
2415        // 50 is not a multiple of 16, and 48 basis vectors leave the residual
2416        // block 2 dimensions to live in. Replacements there were noise and the
2417        // leading value came out several times too large.
2418        let diag: Vec<f64> = (0..50).map(|i| 1.0 + i as f64).collect();
2419        let mat = diagonal(&diag);
2420        let params = BlockLanczosParams {
2421            block_size: 16,
2422            ..BlockLanczosParams::new()
2423        };
2424
2425        let res = block_lanczos_thick_restart(50, 3, 9, params, |x| block_op(&mat, x)).unwrap();
2426
2427        assert!(res.converged);
2428        assert_relative_eq!(res.values[0], 50.0, epsilon = 1e-9);
2429        assert_relative_eq!(res.values[1], 49.0, epsilon = 1e-9);
2430        assert_relative_eq!(res.values[2], 48.0, epsilon = 1e-9);
2431    }
2432
2433    /// An operator returning the wrong shape is an error, not a panic.
2434    #[test]
2435    fn test_block_lanczos_rejects_a_misshapen_operator() {
2436        let res = block_lanczos_thick_restart::<CoreErrors, _>(
2437            10,
2438            2,
2439            1,
2440            BlockLanczosParams::new(),
2441            |_| Ok(Mat::<f64>::zeros(10, 1)),
2442        );
2443
2444        assert!(matches!(res, Err(CoreErrors::InvalidArgument(_))));
2445    }
2446
2447    /// Build a small random CSR operator plus a dense block, both deterministic.
2448    fn dense_block_fixture(
2449        n: usize,
2450        k: usize,
2451        width: usize,
2452        seed: u64,
2453    ) -> (CompressedSparseData2<f64>, Vec<f64>) {
2454        let mut rng = StdRng::seed_from_u64(seed);
2455        let mut indptr = vec![0u32];
2456        let mut indices: Vec<u32> = Vec::new();
2457        let mut data: Vec<f64> = Vec::new();
2458
2459        for _ in 0..n {
2460            // Ascending, unique column indices; some rows deliberately empty.
2461            for j in 0..k {
2462                if rng.random::<f64>() < 0.35 {
2463                    indices.push(j as u32);
2464                    data.push(rng.random::<f64>() * 2.0 - 1.0);
2465                }
2466            }
2467            indptr.push(indices.len() as u32);
2468        }
2469
2470        let csr = CompressedSparseData2::new_csr(&data, &indices, &indptr, None, (n, k));
2471        let block: Vec<f64> = (0..k * width)
2472            .map(|_| rng.random::<f64>() * 2.0 - 1.0)
2473            .collect();
2474
2475        (csr, block)
2476    }
2477
2478    /// The SIMD dense-block kernel must agree with the obvious scalar triple
2479    /// loop, including on rows that carry no non-zeros at all.
2480    #[test]
2481    fn test_csr_matmul_dense_block_matches_scalar() {
2482        // Widths straddling the 2-, 4-, 8- and 16-lane boundaries so every
2483        // axpy tail path is hit.
2484        for width in [1usize, 3, 4, 7, 16, 17, 33] {
2485            let (csr, block) = dense_block_fixture(11, 9, width, 4242 + width as u64);
2486
2487            let mut got = vec![0.0f64; 11 * width];
2488            csr_matmul_dense_block(&csr, &block, width, &mut got).unwrap();
2489
2490            let mut want = vec![0.0f64; 11 * width];
2491            for i in 0..11 {
2492                for p in csr.indptr[i] as usize..csr.indptr[i + 1] as usize {
2493                    let j = csr.indices[p] as usize;
2494                    for c in 0..width {
2495                        want[i * width + c] += csr.data[p] * block[j * width + c];
2496                    }
2497                }
2498            }
2499
2500            for (g, w) in got.iter().zip(want.iter()) {
2501                assert_relative_eq!(g, w, epsilon = 1e-12, max_relative = 1e-12);
2502            }
2503        }
2504    }
2505
2506    /// Applying the operator `t` times must equal multiplying by the explicit
2507    /// `T^t`. This is the whole justification for never materialising the power
2508    /// in MAGIC, so it is worth pinning at a size where the explicit cube is
2509    /// still legal to build.
2510    #[test]
2511    fn test_csr_matmul_dense_block_repeated_matches_explicit_power() {
2512        let width = 5usize;
2513        let n = 8usize;
2514        let (csr, block) = dense_block_fixture(n, n, width, 77);
2515
2516        // Three applications, ping-ponging between two buffers.
2517        let mut buf_a = block.clone();
2518        let mut buf_b = vec![0.0f64; n * width];
2519        for _ in 0..3 {
2520            csr_matmul_dense_block(&csr, &buf_a, width, &mut buf_b).unwrap();
2521            std::mem::swap(&mut buf_a, &mut buf_b);
2522        }
2523
2524        // The explicit cube, applied once.
2525        let sq = csr_matmul_csr(&csr, &csr).unwrap();
2526        let cube = csr_matmul_csr(&sq, &csr).unwrap();
2527        let mut direct = vec![0.0f64; n * width];
2528        csr_matmul_dense_block(&cube, &block, width, &mut direct).unwrap();
2529
2530        for (rep, dir) in buf_a.iter().zip(direct.iter()) {
2531            assert_relative_eq!(rep, dir, epsilon = 1e-10, max_relative = 1e-10);
2532        }
2533    }
2534
2535    /// A row-stochastic operator preserves a constant column, which is the
2536    /// invariant that makes imputed values sit on the input's scale.
2537    #[test]
2538    fn test_csr_matmul_dense_block_preserves_constant_column() {
2539        let n = 6usize;
2540        let width = 3usize;
2541
2542        // Dense positive rows, so nothing is isolated, then row-normalise.
2543        let mut indptr = vec![0u32];
2544        let mut indices: Vec<u32> = Vec::new();
2545        let mut data: Vec<f64> = Vec::new();
2546        for i in 0..n {
2547            for j in 0..n {
2548                indices.push(j as u32);
2549                data.push(1.0 + ((i * n + j) % 3) as f64);
2550            }
2551            indptr.push(indices.len() as u32);
2552        }
2553        let mut csr = CompressedSparseData2::new_csr(&data, &indices, &indptr, None, (n, n));
2554        normalise_csr_rows_l1(&mut csr).unwrap();
2555
2556        let block = vec![2.5f64; n * width];
2557        let mut out = vec![0.0f64; n * width];
2558        csr_matmul_dense_block(&csr, &block, width, &mut out).unwrap();
2559
2560        for v in out {
2561            assert_relative_eq!(v, 2.5, epsilon = 1e-12);
2562        }
2563    }
2564
2565    /// Shape mismatches and a CSC operator must be rejected, not read out of
2566    /// bounds.
2567    #[test]
2568    fn test_csr_matmul_dense_block_rejects_bad_shapes() {
2569        let (csr, block) = dense_block_fixture(4, 4, 2, 1);
2570        let mut out = vec![0.0f64; 4 * 2];
2571
2572        // Wrong width for the block that was built.
2573        assert!(csr_matmul_dense_block(&csr, &block, 3, &mut out).is_err());
2574        // Zero width.
2575        assert!(csr_matmul_dense_block(&csr, &block, 0, &mut out).is_err());
2576        // Output too short.
2577        let mut short = vec![0.0f64; 4];
2578        assert!(csr_matmul_dense_block(&csr, &block, 2, &mut short).is_err());
2579
2580        let csc = csr.transform();
2581        assert!(matches!(
2582            csr_matmul_dense_block(&csc, &block, 2, &mut out),
2583            Err(CoreErrors::SparseMatrixMustBeCsr)
2584        ));
2585    }
2586
2587    /// The sparse ranking must reproduce the dense one, up to the shift.
2588    #[test]
2589    fn test_shifted_ranks_sparse_matches_dense_rank_vector() {
2590        let cases: Vec<Vec<f32>> = vec![
2591            vec![3.0, 0.0, 1.0, 0.0, 0.0],
2592            vec![0.0, 0.0, 0.0, 0.0, 0.0],
2593            vec![-2.0, 0.0, 5.0, 0.0, 0.0, 0.0],
2594            vec![-1.0, -3.0, 0.0, 2.0, 4.0, 0.0, 0.0],
2595            vec![2.0, 2.0, 0.0, 2.0, 0.0, 5.0],
2596            vec![-4.0, -4.0, 0.0, 0.0, -4.0, 1.0],
2597            vec![1.0, 2.0, 3.0, 4.0],
2598            vec![-1.0, -2.0, -3.0],
2599            vec![7.0],
2600        ];
2601
2602        for dense in cases {
2603            let n = dense.len();
2604            let indices: Vec<u32> = (0..n as u32)
2605                .filter(|&i| dense[i as usize] != 0.0)
2606                .collect();
2607            let values: Vec<f32> = indices.iter().map(|&i| dense[i as usize]).collect();
2608
2609            let got = shifted_ranks_sparse(&values, n);
2610            let dense_ranks = rank_vector(&dense);
2611            assert_eq!(got.len(), indices.len());
2612
2613            if indices.is_empty() {
2614                continue;
2615            }
2616
2617            // The ranks themselves must match the dense ones. Only up to a
2618            // constant, because the correlation is shift invariant and a fully
2619            // dense column has no zero block to pin the shift to.
2620            for (slot, &i) in indices.iter().enumerate() {
2621                assert_relative_eq!(
2622                    got[slot] - got[0],
2623                    dense_ranks[i as usize] - dense_ranks[indices[0] as usize],
2624                    epsilon = 1e-6
2625                );
2626            }
2627
2628            // Once a structural zero exists the shift is pinned: it has to put
2629            // that whole tie group at exactly zero.
2630            if indices.len() < n {
2631                let r_zero = dense_ranks[(0..n).find(|&i| dense[i] == 0.0).unwrap()];
2632                for (slot, &i) in indices.iter().enumerate() {
2633                    assert_relative_eq!(
2634                        got[slot],
2635                        dense_ranks[i as usize] - r_zero,
2636                        epsilon = 1e-6
2637                    );
2638                }
2639            }
2640        }
2641    }
2642
2643    /// An explicitly stored zero must land on exactly the same shifted rank as
2644    /// a structural one, namely zero.
2645    ///
2646    /// This is the invariant the closed-form variance and the scatter/gather
2647    /// both stand on: every position the caller did not store contributes a
2648    /// known constant. A stored zero that ranked as its own tie group would
2649    /// break that silently, and CSC data from R does carry explicit zeros.
2650    #[test]
2651    fn test_shifted_ranks_sparse_merges_explicit_zeros() {
2652        // Six rows, four stored, one of them an explicit zero.
2653        let values: Vec<f32> = vec![-2.0, 0.0, 3.0, 1.0];
2654        let got = shifted_ranks_sparse(&values, 6);
2655
2656        assert_relative_eq!(got[1], 0.0, epsilon = 1e-6);
2657        assert!(got[0] < 0.0, "the negative must rank below the zero block");
2658        assert!(got[2] > got[3], "3.0 must rank above 1.0");
2659        assert!(got[3] > 0.0, "positives must rank above the zero block");
2660    }
2661}