Skip to main content

single_svdlib/randomized/
mod.rs

1//! Randomized SVD: range finding by random projection.
2//!
3//! Two sketching strategies, both built on the same reduction:
4//!
5//! - [`Sketch::PowerIteration`] — Halko, Martinsson & Tropp. Cheap and accurate when
6//!   the spectrum decays quickly.
7//! - [`Sketch::BlockKrylov`] — Musco & Musco. Keeps every power-iteration block instead
8//!   of only the last, which is markedly more accurate on slowly-decaying spectra at
9//!   the cost of a wider basis.
10//!
11//! # The final factorization is `l × l`, not `l × cols`
12//!
13//! Once a range basis `Y` (`rows × l`) is in hand, the naive next step is to form
14//! `B = Yᵀ·A` (`l × cols`) and take its dense SVD. `cols` can be large, so 1.x's
15//! `b.svd(true, true)` was a dense factorization of a potentially huge matrix.
16//!
17//! Instead note that `Bᵀ = Aᵀ·Y` is itself tall and skinny (`cols × l`). Factor it with
18//! [`tsqr`](crate::dense::tsqr()) as `Bᵀ = Q_c·R_c`, then the only dense SVD needed is of
19//! `R_cᵀ`, which is `l × l`:
20//!
21//! ```text
22//! A ≈ Y·Bᵀᵀ = Y·R_cᵀ·Q_cᵀ = (Y·Û)·Ŝ·(Q_c·V̂)ᵀ
23//! ```
24//!
25//! With rank 50 and 10 oversamples that is a 60 × 60 factorization regardless of how
26//! wide the input is.
27
28use crate::dense::{small_svd, svd_flip, tsqr};
29use crate::error::{Result, SvdLibError};
30use crate::matrix::SparseMatDense;
31use crate::types::{Algorithm, Detail, Diagnostics, SvdFloat, SvdRec};
32use ndarray::{s, Array1, Array2, Axis};
33use rand::rngs::StdRng;
34use rand::{rng, Rng, SeedableRng};
35use rand_distr::{Distribution, Normal};
36
37/// Default oversampling beyond the requested rank.
38pub const DEFAULT_OVERSAMPLES: usize = 10;
39/// Default power iterations.
40pub const DEFAULT_POWER_ITERATIONS: usize = 2;
41
42/// How the intermediate basis is re-orthogonalised between products.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44pub enum Normalizer {
45    /// Tall-skinny QR. Numerically the right default.
46    #[default]
47    Tsqr,
48    /// Column normalisation only. Cheaper, and adequate for one or two iterations, but
49    /// it does not prevent the basis collapsing toward the dominant direction.
50    ColumnNorm,
51    /// No re-orthogonalisation. Only safe with zero power iterations.
52    None,
53}
54
55/// The sketching strategy.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum Sketch {
58    /// `Y = (A·Aᵀ)^q·A·Ω`, keeping only the final block.
59    ///
60    /// Basis width is `rank + oversamples`.
61    PowerIteration { iterations: usize },
62    /// `K = [A·Ω, (A·Aᵀ)A·Ω, …, (A·Aᵀ)^(b-1)·A·Ω]`, keeping every block.
63    ///
64    /// Basis width is `blocks · (rank + oversamples)`, so memory scales with `blocks`.
65    /// Two to four blocks is usually the sweet spot.
66    BlockKrylov { blocks: usize },
67}
68
69impl Default for Sketch {
70    fn default() -> Self {
71        Sketch::PowerIteration {
72            iterations: DEFAULT_POWER_ITERATIONS,
73        }
74    }
75}
76
77/// Configuration for [`svd_with`].
78///
79/// Replaces 1.x's eight positional arguments, two of which were bare `bool`s that the
80/// crate's own call sites commented incorrectly.
81#[derive(Debug, Clone)]
82pub struct RandomizedConfig {
83    /// Number of singular triplets wanted.
84    pub rank: usize,
85    /// Extra sketch columns; improves accuracy at linear cost.
86    pub oversamples: usize,
87    pub sketch: Sketch,
88    pub normalizer: Normalizer,
89    /// Subtract column means without materialising the centered matrix.
90    pub mean_center: bool,
91    /// Fixed seed; `None` draws from the OS.
92    ///
93    /// 1.x accepted `Option<u64>` but substituted `0` for `None`, so "random" was in
94    /// fact a fixed sketch on every call.
95    pub seed: Option<u64>,
96}
97
98impl RandomizedConfig {
99    pub fn new(rank: usize) -> Self {
100        Self {
101            rank,
102            oversamples: DEFAULT_OVERSAMPLES,
103            sketch: Sketch::default(),
104            normalizer: Normalizer::default(),
105            mean_center: false,
106            seed: None,
107        }
108    }
109    pub fn oversamples(mut self, n: usize) -> Self {
110        self.oversamples = n;
111        self
112    }
113    pub fn power_iterations(mut self, n: usize) -> Self {
114        self.sketch = Sketch::PowerIteration { iterations: n };
115        self
116    }
117    pub fn block_krylov(mut self, blocks: usize) -> Self {
118        self.sketch = Sketch::BlockKrylov { blocks };
119        self
120    }
121    pub fn normalizer(mut self, n: Normalizer) -> Self {
122        self.normalizer = n;
123        self
124    }
125    pub fn mean_center(mut self, yes: bool) -> Self {
126        self.mean_center = yes;
127        self
128    }
129    pub fn seed(mut self, seed: u64) -> Self {
130        self.seed = Some(seed);
131        self
132    }
133}
134
135/// A sink for progress messages, called once per major stage.
136///
137/// 1.x printed stage timings to stdout behind a `verbose: bool`. A library shouldn't
138/// write to the process's streams, so the caller supplies the sink.
139pub type Progress<'a> = &'a (dyn Fn(&str) + Sync);
140
141/// `rank` largest singular triplets with default settings.
142pub fn svd<T: SvdFloat, M: SparseMatDense<T>>(a: &M, rank: usize) -> Result<SvdRec<T>> {
143    svd_with(a, &RandomizedConfig::new(rank), None)
144}
145
146/// `rank` largest singular triplets with a fixed seed.
147pub fn svd_seed<T: SvdFloat, M: SparseMatDense<T>>(
148    a: &M,
149    rank: usize,
150    seed: u64,
151) -> Result<SvdRec<T>> {
152    svd_with(a, &RandomizedConfig::new(rank).seed(seed), None)
153}
154
155/// Block-Krylov variant with `blocks` blocks.
156pub fn svd_block_krylov<T: SvdFloat, M: SparseMatDense<T>>(
157    a: &M,
158    rank: usize,
159    blocks: usize,
160    seed: Option<u64>,
161) -> Result<SvdRec<T>> {
162    let mut cfg = RandomizedConfig::new(rank).block_krylov(blocks);
163    cfg.seed = seed;
164    svd_with(a, &cfg, None)
165}
166
167/// PCA: `rank` largest triplets of the implicitly mean-centered matrix.
168pub fn svd_centered<T: SvdFloat, M: SparseMatDense<T>>(
169    a: &M,
170    rank: usize,
171    seed: Option<u64>,
172) -> Result<SvdRec<T>> {
173    let mut cfg = RandomizedConfig::new(rank).mean_center(true);
174    cfg.seed = seed;
175    svd_with(a, &cfg, None)
176}
177
178/// Compute a decomposition with explicit configuration.
179pub fn svd_with<T: SvdFloat, M: SparseMatDense<T>>(
180    a: &M,
181    cfg: &RandomizedConfig,
182    progress: Option<Progress<'_>>,
183) -> Result<SvdRec<T>> {
184    let note = |msg: &str| {
185        if let Some(p) = progress {
186            p(msg);
187        }
188    };
189
190    let (rows, cols) = (a.rows(), a.cols());
191    let min_dim = rows.min(cols);
192    if cfg.rank == 0 {
193        return Err(SvdLibError::invalid("randomized: rank must be at least 1"));
194    }
195    if cfg.rank > min_dim {
196        return Err(SvdLibError::invalid(format!(
197            "randomized: rank {} exceeds min(rows, cols) = {min_dim}",
198            cfg.rank
199        )));
200    }
201    if let Sketch::BlockKrylov { blocks } = cfg.sketch {
202        if blocks == 0 {
203            return Err(SvdLibError::invalid(
204                "randomized: block_krylov needs at least one block",
205            ));
206        }
207    }
208
209    let rank = cfg.rank;
210    // Sketch width, capped so the basis cannot exceed the operand's rank.
211    let l = (rank + cfg.oversamples).min(min_dim);
212    let seed = cfg.seed.unwrap_or_else(|| rng().next_u64());
213    let mut rng_state = StdRng::seed_from_u64(seed);
214
215    let means: Option<Array1<T>> = if cfg.mean_center {
216        note("computing column means");
217        Some(a.col_means())
218    } else {
219        None
220    };
221    let mut matvecs = 0usize;
222
223    // Product helpers that apply centering when configured.
224    let mul = |rhs: &Array2<T>, out: &mut Array2<T>, trans: bool| match &means {
225        Some(m) => a.mul_dense_centered(rhs.view(), out.view_mut(), trans, m.view()),
226        None => a.mul_dense(rhs.view(), out.view_mut(), trans),
227    };
228
229    note("drawing the random sketch");
230    let omega = gaussian(cols, l, &mut rng_state);
231
232    // ----- Stage 1: build a basis for the range of A -----
233    let mut basis = match cfg.sketch {
234        Sketch::PowerIteration { iterations } => {
235            note("projecting");
236            let mut y = Array2::<T>::zeros((rows, l));
237            mul(&omega, &mut y, false);
238            matvecs += l;
239            normalize(&mut y, cfg.normalizer)?;
240
241            let mut z = Array2::<T>::zeros((cols, l));
242            for i in 0..iterations {
243                note(&format!("power iteration {}/{}", i + 1, iterations));
244                mul(&y, &mut z, true);
245                matvecs += l;
246                normalize(&mut z, cfg.normalizer)?;
247                mul(&z, &mut y, false);
248                matvecs += l;
249                normalize(&mut y, cfg.normalizer)?;
250            }
251            y
252        }
253        Sketch::BlockKrylov { blocks } => {
254            note("building the Krylov block basis");
255            // The range of A has dimension at most min(rows, cols), so a basis wider
256            // than that is necessarily rank-deficient. Clamping to `rows` alone is not
257            // enough: on a 500x60 operand, 4 blocks of 22 would give an 88-column basis
258            // whose `Aᵀ·basis` is 60x88 — wider than tall, which no QR accepts.
259            let width = (blocks * l).min(min_dim);
260            let mut k = Array2::<T>::zeros((rows, width));
261            let mut y = Array2::<T>::zeros((rows, l));
262            let mut z = Array2::<T>::zeros((cols, l));
263
264            mul(&omega, &mut y, false);
265            matvecs += l;
266            normalize(&mut y, cfg.normalizer)?;
267
268            let mut filled = 0usize;
269            for b in 0..blocks {
270                if filled >= width {
271                    break;
272                }
273                let take = l.min(width - filled);
274                k.slice_mut(s![.., filled..filled + take])
275                    .assign(&y.slice(s![.., ..take]));
276                filled += take;
277                if b + 1 == blocks {
278                    break;
279                }
280                note(&format!("krylov block {}/{}", b + 2, blocks));
281                mul(&y, &mut z, true);
282                matvecs += l;
283                normalize(&mut z, cfg.normalizer)?;
284                mul(&z, &mut y, false);
285                matvecs += l;
286                normalize(&mut y, cfg.normalizer)?;
287            }
288            if filled < width {
289                k = k.slice(s![.., ..filled]).to_owned();
290            }
291            k
292        }
293    };
294
295    note("orthonormalising the basis");
296    tsqr(&mut basis)?;
297    let width = basis.ncols();
298
299    // ----- Stage 2: project and factor -----
300    //
301    // `bt = Aᵀ·basis` is tall-skinny, so TSQR it and take the SVD of the small `R`
302    // rather than factoring the wide `basis ᵀ·A` directly.
303    note("projecting onto the basis");
304    let mut bt = Array2::<T>::zeros((cols, width));
305    mul(&basis, &mut bt, true);
306    matvecs += width;
307
308    note("reducing");
309    let r_c = tsqr(&mut bt)?; // bt is now Q_c (cols × width), r_c is width × width
310    let small = small_svd(r_c.t())?; // SVD of R_cᵀ
311
312    // A ≈ (basis·Û)·Ŝ·(Q_c·V̂)ᵀ
313    let keep = rank.min(small.s.len());
314    let u_hat = small.u.slice(s![.., ..keep]);
315    let v_hat = small.vt.slice(s![..keep, ..]).t().to_owned(); // width × keep
316
317    let mut u = basis.dot(&u_hat);
318    let mut vt = bt
319        .dot(&v_hat)
320        .reversed_axes()
321        .as_standard_layout()
322        .to_owned();
323    let s = small.s.slice(s![..keep]).to_owned();
324
325    svd_flip(&mut u, &mut vt);
326
327    let (oversamples, power_iterations, block_size) = match cfg.sketch {
328        Sketch::PowerIteration { iterations } => (cfg.oversamples, iterations, l),
329        Sketch::BlockKrylov { blocks } => (cfg.oversamples, blocks, l),
330    };
331
332    Ok(SvdRec {
333        d: keep,
334        u,
335        s,
336        vt,
337        total_squared_norm: T::from_f64_val(crate::matrix::total_squared_norm(
338            a,
339            means.as_ref().map(|m| m.view()),
340        )),
341        diagnostics: Diagnostics {
342            algorithm: match cfg.sketch {
343                Sketch::PowerIteration { .. } => Algorithm::Randomized,
344                Sketch::BlockKrylov { .. } => Algorithm::BlockKrylov,
345            },
346            non_zero: a.nnz(),
347            dimensions: rank,
348            significant_values: keep,
349            transposed: false,
350            random_seed: seed,
351            matvecs,
352            detail: Detail::Randomized {
353                oversamples,
354                power_iterations,
355                block_size,
356            },
357        },
358    })
359}
360
361/// A `rows × cols` matrix of standard normal draws.
362fn gaussian<T: SvdFloat>(rows: usize, cols: usize, rng: &mut StdRng) -> Array2<T> {
363    let normal = Normal::new(0.0, 1.0).expect("N(0,1) is well-formed");
364    Array2::from_shape_fn((rows, cols), |_| T::from_f64_val(normal.sample(rng)))
365}
366
367fn normalize<T: SvdFloat>(m: &mut Array2<T>, how: Normalizer) -> Result<()> {
368    match how {
369        Normalizer::Tsqr => {
370            tsqr(m)?;
371            Ok(())
372        }
373        Normalizer::ColumnNorm => {
374            let floor = T::from_f64_val(1e-10);
375            for mut col in m.axis_iter_mut(Axis(1)) {
376                let n = col.iter().map(|&x| x * x).sum::<T>().sqrt();
377                if n > floor {
378                    let inv = T::one() / n;
379                    col.map_inplace(|x| *x *= inv);
380                }
381            }
382            Ok(())
383        }
384        Normalizer::None => Ok(()),
385    }
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use crate::matrix::SvdMat;
392    use crate::testing::{dense_of, gen_lowrank, gen_sparse, reference_singular_values, Lcg};
393    use sprs::TriMatI;
394
395    fn diagonal(n: usize) -> SvdMat<f64> {
396        let mut t = TriMatI::<f64, u32>::new((n, n));
397        for i in 0..n {
398            t.add_triplet(i, i, (n - i) as f64);
399        }
400        t.to_csr::<u64>()
401    }
402
403    /// Worst relative error against a dense LAPACK reference.
404    fn max_rel_error(a: &SvdMat<f64>, got: &SvdRec<f64>) -> f64 {
405        let want = reference_singular_values(&dense_of(a));
406        got.s
407            .iter()
408            .enumerate()
409            .map(|(i, &g)| (g - want[i]).abs() / want[i].abs().max(1e-30))
410            .fold(0.0f64, f64::max)
411    }
412
413    /// A rapidly-decaying spectrum is the regime randomized SVD is designed for, so
414    /// accuracy there should be high even with modest power iterations.
415    #[test]
416    fn accurate_on_decaying_spectrum() {
417        let a = gen_lowrank(400, 120, 10, 5);
418        let got = svd_seed(&a, 10, 42).unwrap();
419        let err = max_rel_error(&a, &got);
420        assert!(err < 1e-6, "max relative error {err:.3e}");
421    }
422
423    /// `diag(60..1)` decays only linearly, so `sigma_11/sigma_10 = 0.98` and the
424    /// randomized error bound `~(sigma_{k+1}/sigma_k)^(2q+1)` barely improves with `q`.
425    /// This is a limitation of the method, not a defect: assert the behaviour theory
426    /// predicts rather than an accuracy it cannot deliver.
427    #[test]
428    fn power_iteration_converges_slowly_on_linear_decay() {
429        let a = diagonal(60);
430        let loose = svd_with(
431            &a,
432            &RandomizedConfig::new(10).seed(42).power_iterations(0),
433            None,
434        )
435        .unwrap();
436        let tight = svd_with(
437            &a,
438            &RandomizedConfig::new(10).seed(42).power_iterations(7),
439            None,
440        )
441        .unwrap();
442        let e0 = max_rel_error(&a, &loose);
443        let e7 = max_rel_error(&a, &tight);
444        assert!(
445            e0 > 1e-2,
446            "q=0 should be visibly inaccurate here, got {e0:.3e}"
447        );
448        assert!(
449            e7 < e0 / 50.0,
450            "7 power iterations should improve substantially: {e0:.3e} -> {e7:.3e}"
451        );
452    }
453
454    /// Block Krylov is the answer for that same matrix: retaining every block spans the
455    /// dominant subspace essentially exactly, reaching machine precision where power
456    /// iteration is still at 1e-4.
457    #[test]
458    fn block_krylov_is_near_exact_on_linear_decay() {
459        let a = diagonal(60);
460        let got = svd_with(
461            &a,
462            &RandomizedConfig::new(10).seed(42).block_krylov(4),
463            None,
464        )
465        .unwrap();
466        let err = max_rel_error(&a, &got);
467        assert!(err < 1e-10, "block krylov max relative error {err:.3e}");
468    }
469
470    /// More power iterations must not make the answer worse.
471    #[test]
472    fn power_iterations_improve_accuracy() {
473        let a = gen_sparse(600, 200, 0.05, 13);
474        let mut prev = f64::INFINITY;
475        for q in [0usize, 1, 2, 4, 7] {
476            let cfg = RandomizedConfig::new(15).seed(42).power_iterations(q);
477            let got = svd_with(&a, &cfg, None).unwrap();
478            let err = max_rel_error(&a, &got);
479            assert!(
480                err <= prev * 1.5 + 1e-9,
481                "q={q} error {err:.3e} is worse than q's predecessor {prev:.3e}"
482            );
483            prev = err;
484        }
485        // A near-flat spectrum (ratio 0.999) cannot be driven to high accuracy by
486        // power iteration at any practical `q`; what must hold is a large improvement
487        // over the un-iterated sketch.
488        let plain = svd_with(
489            &a,
490            &RandomizedConfig::new(15).seed(42).power_iterations(0),
491            None,
492        )
493        .unwrap();
494        let e0 = max_rel_error(&a, &plain);
495        assert!(
496            prev < e0 / 10.0,
497            "7 power iterations ({prev:.3e}) should be well under the q=0 error ({e0:.3e})"
498        );
499    }
500
501    /// Block Krylov should beat plain power iteration at equal matrix-product budget on
502    /// a slowly-decaying spectrum, which is exactly what it exists for.
503    #[test]
504    fn block_krylov_beats_power_iteration_on_flat_spectrum() {
505        // A near-flat spectrum: random sparse, no low-rank structure.
506        let a = gen_sparse(800, 200, 0.04, 29);
507        let rank = 20;
508
509        let power = svd_with(
510            &a,
511            &RandomizedConfig::new(rank).seed(42).power_iterations(3),
512            None,
513        )
514        .unwrap();
515        let krylov = svd_with(
516            &a,
517            &RandomizedConfig::new(rank).seed(42).block_krylov(4),
518            None,
519        )
520        .unwrap();
521
522        let e_power = max_rel_error(&a, &power);
523        let e_krylov = max_rel_error(&a, &krylov);
524        assert!(
525            e_krylov <= e_power,
526            "block krylov {e_krylov:.3e} did not improve on power iteration {e_power:.3e}"
527        );
528        assert_eq!(krylov.diagnostics.algorithm, Algorithm::BlockKrylov);
529    }
530
531    #[test]
532    fn orientation_is_correct_for_wide_and_tall() {
533        for (r, c) in [(400usize, 80usize), (80, 400)] {
534            let a = gen_sparse(r, c, 0.08, 11);
535            let got = svd_seed(&a, 10, 42).unwrap();
536            assert_eq!(got.u.dim(), (r, 10), "u shape for {r}x{c}");
537            assert_eq!(got.vt.dim(), (10, c), "vt shape for {r}x{c}");
538        }
539    }
540
541    #[test]
542    fn singular_vectors_are_orthonormal() {
543        let a = gen_lowrank(300, 100, 12, 71);
544        let got = svd_seed(&a, 12, 42).unwrap();
545        let ou = crate::dense::orthogonality_error(&got.u.view());
546        assert!(ou < 1e-8, "||UᵀU - I|| = {ou:.3e}");
547        let vt_t = got.vt.t().to_owned();
548        let ov = crate::dense::orthogonality_error(&vt_t.view());
549        assert!(ov < 1e-8, "||VᵀV - I|| = {ov:.3e}");
550    }
551
552    /// The whole point of the 2.0 rewrite: this used to panic with `todo!()` for every
553    /// stock matrix type.
554    #[test]
555    fn works_on_csr_and_csc_without_panicking() {
556        let a = gen_sparse(300, 120, 0.05, 3);
557        let csc = a.to_other_storage();
558        let x = svd_seed(&a, 10, 42).unwrap();
559        let y = svd_seed(&csc, 10, 42).unwrap();
560        for (p, q) in x.s.iter().zip(y.s.iter()) {
561            approx::assert_relative_eq!(p, q, max_relative = 1e-9);
562        }
563    }
564
565    #[test]
566    fn works_on_masked_matrices() {
567        let a = gen_sparse(300, 60, 0.1, 19);
568        let cols: Vec<usize> = (0..60).filter(|c| c % 2 == 0).collect();
569        let masked = crate::matrix::MaskedCsMat::with_columns(&a, &cols);
570        let got = svd_seed(&masked, 8, 42).unwrap();
571        assert_eq!(got.u.nrows(), 300);
572        assert_eq!(got.vt.ncols(), 30);
573        for w in got.s.to_vec().windows(2) {
574            assert!(w[0] >= w[1]);
575        }
576    }
577
578    /// Mean centering must agree with an explicitly centered dense reference.
579    #[test]
580    fn mean_centering_matches_dense_pca() {
581        let a = gen_lowrank(300, 60, 8, 37);
582        let dense = dense_of(&a);
583        let means = dense.mean_axis(Axis(0)).unwrap();
584        let centered = &dense - &means.view().insert_axis(Axis(0));
585        let want = reference_singular_values(&centered);
586
587        let cfg = RandomizedConfig::new(8)
588            .seed(42)
589            .mean_center(true)
590            .power_iterations(5);
591        let got = svd_with(&a, &cfg, None).unwrap();
592        for (i, &g) in got.s.iter().enumerate() {
593            let rel = (g - want[i]).abs() / want[i].abs().max(1e-30);
594            assert!(
595                rel < 1e-5,
596                "centered singular value {i}: {g:.9e} vs {:.9e} (rel {rel:.3e})",
597                want[i]
598            );
599        }
600    }
601
602    /// `None` must actually vary the sketch. 1.x substituted seed 0 for `None`, so
603    /// successive calls were identical.
604    #[test]
605    fn unseeded_runs_differ() {
606        let a = gen_sparse(300, 100, 0.05, 47);
607        let cfg = RandomizedConfig::new(6).power_iterations(0);
608        let x = svd_with(&a, &cfg, None).unwrap();
609        let y = svd_with(&a, &cfg, None).unwrap();
610        assert_ne!(
611            x.diagnostics.random_seed, y.diagnostics.random_seed,
612            "an unseeded config produced the same seed twice"
613        );
614        // Zero power iterations makes the sketch dependence visible in the output.
615        assert_ne!(x.u, y.u, "unseeded runs produced identical bases");
616    }
617
618    #[test]
619    fn seeded_runs_are_reproducible() {
620        let a = gen_sparse(300, 100, 0.05, 51);
621        let x = svd_seed(&a, 8, 999).unwrap();
622        let y = svd_seed(&a, 8, 999).unwrap();
623        assert_eq!(x.s, y.s);
624        assert_eq!(x.u, y.u);
625        assert_eq!(x.vt, y.vt);
626    }
627
628    #[test]
629    fn agrees_with_irlba() {
630        let a = gen_lowrank(400, 150, 12, 61);
631        let rand = svd_with(
632            &a,
633            &RandomizedConfig::new(12).seed(42).power_iterations(6),
634            None,
635        )
636        .unwrap();
637        let exact = crate::irlba::svd_seed(&a, 12, 42).unwrap();
638        for i in 0..12 {
639            let rel = (rand.s[i] - exact.s[i]).abs() / exact.s[i];
640            assert!(rel < 1e-6, "triplet {i}: randomized vs irlba rel {rel:.3e}");
641        }
642    }
643
644    #[test]
645    fn normalizers_all_produce_usable_results() {
646        let a = gen_lowrank(400, 100, 10, 67);
647        for n in [Normalizer::Tsqr, Normalizer::ColumnNorm, Normalizer::None] {
648            let cfg = RandomizedConfig::new(10)
649                .seed(42)
650                .power_iterations(1)
651                .normalizer(n);
652            let got = svd_with(&a, &cfg, None).unwrap();
653            let err = max_rel_error(&a, &got);
654            assert!(err < 1e-2, "{n:?} gave max relative error {err:.3e}");
655        }
656    }
657
658    #[test]
659    fn progress_callback_is_invoked() {
660        let a = gen_sparse(200, 80, 0.1, 73);
661        let seen = std::sync::Mutex::new(Vec::<String>::new());
662        let sink = |msg: &str| seen.lock().unwrap().push(msg.to_string());
663        let cfg = RandomizedConfig::new(6).seed(42).power_iterations(2);
664        svd_with(&a, &cfg, Some(&sink)).unwrap();
665        let msgs = seen.into_inner().unwrap();
666        assert!(!msgs.is_empty(), "no progress reported");
667        assert!(
668            msgs.iter().any(|m| m.contains("power iteration")),
669            "power iterations were not reported: {msgs:?}"
670        );
671    }
672
673    /// Regression: a block count whose basis would exceed `min(rows, cols)` must clamp
674    /// rather than hand a wide matrix to the QR.
675    #[test]
676    fn block_krylov_clamps_basis_to_matrix_rank() {
677        // 500x60 with rank 12 + 10 oversamples = 22 per block; 4 blocks would be 88.
678        let a = gen_sparse(500, 60, 0.08, 7);
679        let got = svd_block_krylov(&a, 12, 4, Some(42)).expect("should clamp, not fail");
680        assert_eq!(got.d, 12);
681        assert_eq!(got.u.dim(), (500, 12));
682        assert_eq!(got.vt.dim(), (12, 60));
683
684        // Also the wide orientation.
685        let b = gen_sparse(60, 500, 0.08, 11);
686        let got = svd_block_krylov(&b, 12, 4, Some(42)).expect("should clamp, not fail");
687        assert_eq!(got.u.dim(), (60, 12));
688        assert_eq!(got.vt.dim(), (12, 500));
689    }
690
691    #[test]
692    fn rejects_bad_configuration() {
693        let a = gen_sparse(50, 30, 0.2, 1);
694        assert!(matches!(svd(&a, 0), Err(SvdLibError::InvalidArgument(_))));
695        assert!(matches!(svd(&a, 31), Err(SvdLibError::InvalidArgument(_))));
696        let cfg = RandomizedConfig::new(5).block_krylov(0);
697        assert!(matches!(
698            svd_with(&a, &cfg, None),
699            Err(SvdLibError::InvalidArgument(_))
700        ));
701    }
702
703    #[test]
704    fn f32_works() {
705        let a64 = gen_lowrank(300, 80, 8, 79);
706        let want = reference_singular_values(&dense_of(&a64));
707        let mut t = TriMatI::<f32, u32>::new((300, 80));
708        for (v, (i, j)) in a64.iter() {
709            t.add_triplet(i as usize, j as usize, *v as f32);
710        }
711        let a32: SvdMat<f32> = t.to_csr::<u64>();
712        let cfg = RandomizedConfig::new(8).seed(42).power_iterations(4);
713        let got = svd_with(&a32, &cfg, None).unwrap();
714        for (i, &g) in got.s.iter().enumerate() {
715            let rel = ((g as f64) - want[i]).abs() / want[i].abs().max(1e-30);
716            assert!(rel < 1e-3, "f32 singular value {i}: rel {rel:.3e}");
717        }
718    }
719
720    /// Oversampling beyond the operand's rank must clamp rather than overrun.
721    #[test]
722    fn oversampling_clamps_to_matrix_rank() {
723        let a = gen_sparse(40, 20, 0.3, 83);
724        let cfg = RandomizedConfig::new(5).seed(42).oversamples(1000);
725        let got = svd_with(&a, &cfg, None).unwrap();
726        assert_eq!(got.d, 5);
727        let mut rng = Lcg::new(1);
728        let _ = rng.next_u64();
729    }
730
731    #[test]
732    fn diagnostics_report_matvecs_and_algorithm() {
733        let a = gen_sparse(200, 80, 0.1, 89);
734        let got = svd_seed(&a, 6, 42).unwrap();
735        assert_eq!(got.diagnostics.algorithm, Algorithm::Randomized);
736        assert!(got.diagnostics.matvecs > 0);
737        match got.diagnostics.detail {
738            Detail::Randomized {
739                power_iterations, ..
740            } => {
741                assert_eq!(power_iterations, DEFAULT_POWER_ITERATIONS);
742            }
743            ref other => panic!("wrong detail variant: {other:?}"),
744        }
745    }
746
747    /// Characterises how each sketch converges as a function of spectral decay. Run
748    /// with `--ignored --nocapture` to see the table; it is the evidence behind the
749    /// guidance in the module docs about when to prefer block Krylov.
750    #[test]
751    #[ignore = "diagnostic, run explicitly"]
752    fn report_convergence_rates() {
753        let cases: Vec<(&str, SvdMat<f64>, usize)> = vec![
754            ("diag_60_linear", diagonal(60), 10),
755            ("lowrank_400x120_r10", gen_lowrank(400, 120, 10, 5), 10),
756            ("sparse_600x200_flat", gen_sparse(600, 200, 0.05, 13), 15),
757        ];
758        for (name, a, rank) in cases {
759            let want = reference_singular_values(&dense_of(&a));
760            print!(
761                "{name:<22} sigma_ratio={:.3}  ",
762                want[rank] / want[rank - 1]
763            );
764            for q in [0usize, 1, 2, 4, 7] {
765                let cfg = RandomizedConfig::new(rank).seed(42).power_iterations(q);
766                let got = svd_with(&a, &cfg, None).unwrap();
767                print!("q{q}={:.2e} ", max_rel_error(&a, &got));
768            }
769            for b in [2usize, 4] {
770                let cfg = RandomizedConfig::new(rank).seed(42).block_krylov(b);
771                let got = svd_with(&a, &cfg, None).unwrap();
772                print!("bk{b}={:.2e} ", max_rel_error(&a, &got));
773            }
774            println!();
775        }
776    }
777}