Skip to main content

single_svdlib/lanczos/
mod.rs

1//! Single-vector Lanczos with selective reorthogonalization — a port of LAS2 from
2//! Doug Rohde's SVDLIBC.
3//!
4//! # ⚠ This module is deprecated and numerically unreliable
5//!
6//! LAS2 as implemented here does **not** agree with a dense LAPACK reference on any
7//! matrix class tested. The largest singular value comes back with 18%–100% relative
8//! error, including on `diag(n, n-1, ..., 1)` at full requested rank. The defect is
9//! inherited from published 1.x, not introduced by the sprs port — running
10//! `single-svdlib 1.0.9` on identical fixtures reproduces the same wrong values.
11//!
12//! Two causes are known:
13//!
14//! 1. **Fixed.** `imtqlb` hoisted its shift origin out of the iteration loop, so every
15//!    eigenvalue after the first used a stale shift. EISPACK `IMTQL1` assigns
16//!    `p = d(l)` inside the loop. This is what produced the "imtqlb had some
17//!    convergence issues" warnings 1.x printed on nearly every input before continuing
18//!    with corrupted Ritz values.
19//! 2. **Open.** `ritvec` reads `s[k*js + i]` — row `k` — while `imtql2` stores
20//!    eigenvectors as columns. Transposing roughly halves the residual error but does
21//!    not eliminate it, so at least one further defect remains.
22//!
23//! Use [`crate::irlba`] instead: restarted Lanczos bidiagonalization, validated against
24//! LAPACK, with a Krylov basis bounded by the requested rank rather than growing to
25//! `min(rows, cols)`.
26//!
27//! The module is retained so 2.0 does not silently drop the API, and so the repair has
28//! a home. The accuracy tests are present but `#[ignore]`d, and
29//! `report_accuracy_vs_lapack` prints the current error profile.
30
31// Numeric kernels index several arrays in step from one loop variable, and
32// offset arithmetic is load-bearing; iterator rewrites obscure which array an
33// index belongs to.
34#![allow(clippy::needless_range_loop)]
35#![allow(clippy::manual_checked_ops)]
36
37use crate::error::{Result, SvdLibError};
38use crate::matrix::SparseMat;
39use crate::types::{Algorithm, Detail, Diagnostics, SvdFloat, SvdRec};
40use ndarray::{Array1, Array2};
41use num_traits::Float;
42use rand::rngs::StdRng;
43use rand::{rng, Rng, RngExt, SeedableRng};
44use rayon::prelude::*;
45use std::cell::Cell;
46use std::mem;
47
48const MAXLL: usize = 2;
49const MAX_QL_ITERATIONS: usize = 100;
50
51/// Default end interval: eigenvalues inside it are considered unwanted.
52pub const DEFAULT_END_INTERVAL: [f64; 2] = [-1.0e-30, 1.0e-30];
53/// Default relative accuracy for accepting a Ritz value as an eigenvalue.
54pub const DEFAULT_KAPPA: f64 = 1.0e-6;
55
56/// SVD at full dimensionality with default tolerances.
57#[deprecated(
58    since = "2.0.0",
59    note = "LAS2 is numerically unreliable (18%-100% error vs LAPACK); use `single_svdlib::irlba` instead. See the module docs."
60)]
61pub fn svd<T: SvdFloat, M: SparseMat<T>>(a: &M) -> Result<SvdRec<T>> {
62    #[allow(deprecated)]
63    svd_dim_seed(a, 0, 0)
64}
65
66/// SVD at the requested dimensionality with default tolerances.
67///
68/// `dimensions == 0` means `min(rows, cols)`.
69#[deprecated(
70    since = "2.0.0",
71    note = "LAS2 is numerically unreliable (18%-100% error vs LAPACK); use `single_svdlib::irlba` instead. See the module docs."
72)]
73pub fn svd_dim<T: SvdFloat, M: SparseMat<T>>(a: &M, dimensions: usize) -> Result<SvdRec<T>> {
74    #[allow(deprecated)]
75    svd_dim_seed(a, dimensions, 0)
76}
77
78/// SVD at the requested dimensionality with a fixed seed.
79///
80/// `random_seed == 0` draws a seed from the OS.
81#[deprecated(
82    since = "2.0.0",
83    note = "LAS2 is numerically unreliable (18%-100% error vs LAPACK); use `single_svdlib::irlba` instead. See the module docs."
84)]
85pub fn svd_dim_seed<T: SvdFloat, M: SparseMat<T>>(
86    a: &M,
87    dimensions: usize,
88    random_seed: u64,
89) -> Result<SvdRec<T>> {
90    #[allow(deprecated)]
91    svd_las2(
92        a,
93        dimensions,
94        0,
95        &[
96            T::from_f64_val(DEFAULT_END_INTERVAL[0]),
97            T::from_f64_val(DEFAULT_END_INTERVAL[1]),
98        ],
99        T::from_f64_val(DEFAULT_KAPPA),
100        random_seed,
101    )
102}
103
104/// Compute a singular value decomposition with full control.
105///
106/// - `dimensions`: upper limit on singular triplets, `0` for `min(rows, cols)`
107/// - `iterations`: upper limit on Lanczos steps, `0` for `min(rows, cols)`; clamped
108///   into `[dimensions, min(rows, cols)]`
109/// - `end_interval`: interval bracketing unwanted (near-zero) eigenvalues
110/// - `kappa`: relative accuracy for accepting Ritz values, floored at `eps^(3/4)`
111/// - `random_seed`: `0` draws from the OS
112///
113/// Singular values come back in descending order, with `u` as `m × d` and `vt` as
114/// `d × n`.
115#[deprecated(
116    since = "2.0.0",
117    note = "LAS2 is numerically unreliable (18%-100% error vs LAPACK); use `single_svdlib::irlba` instead. See the module docs."
118)]
119pub fn svd_las2<T: SvdFloat, M: SparseMat<T>>(
120    a: &M,
121    dimensions: usize,
122    iterations: usize,
123    end_interval: &[T; 2],
124    kappa: T,
125    random_seed: u64,
126) -> Result<SvdRec<T>> {
127    let random_seed = if random_seed > 0 {
128        random_seed
129    } else {
130        rng().next_u64()
131    };
132
133    let min_dim = a.rows().min(a.cols());
134    if min_dim < 2 {
135        return Err(SvdLibError::invalid(format!(
136            "svd_las2 needs both dimensions >= 2, got {}x{}",
137            a.rows(),
138            a.cols()
139        )));
140    }
141
142    let dimensions = match dimensions {
143        n if n == 0 || n > min_dim => min_dim,
144        n => n,
145    };
146    let iterations = match iterations {
147        n if n == 0 || n > min_dim => min_dim,
148        n if n < dimensions => dimensions,
149        n => n,
150    };
151    if dimensions < 2 {
152        return Err(SvdLibError::invalid(format!(
153            "svd_las2: insufficient dimensions: {dimensions}"
154        )));
155    }
156
157    // Working on the transpose keeps the Lanczos vectors over the smaller dimension.
158    let transposed = (a.cols() as f64) >= (a.rows() as f64) * 1.2;
159    let nrows = if transposed { a.cols() } else { a.rows() };
160    let ncols = if transposed { a.rows() } else { a.cols() };
161
162    let mut wrk = WorkSpace::new(nrows, ncols, transposed, iterations);
163    let mut store = Store::new(ncols);
164    let tuning = Tuning::for_matrix(a.nnz(), a.rows(), a.cols());
165
166    let mut neig = 0;
167    let steps = lanso(
168        a,
169        dimensions,
170        iterations,
171        end_interval,
172        &mut wrk,
173        &mut neig,
174        &mut store,
175        random_seed,
176        &tuning,
177    )?;
178
179    let kappa = Float::max(Float::abs(kappa), T::eps34());
180    let mut raw = ritvec(
181        a, dimensions, kappa, &mut wrk, steps, neig, &mut store, &tuning,
182    )?;
183
184    if transposed {
185        mem::swap(&mut raw.ut, &mut raw.vt);
186    }
187
188    let d = raw.d;
189    // `ut` is stored d x m row-major; the public contract is u as m x d.
190    let u = Array2::from_shape_vec((d, raw.ut.cols), raw.ut.value)?
191        .t()
192        .to_owned();
193    let s = Array1::from_vec(raw.s);
194    let vt = Array2::from_shape_vec((d, raw.vt.cols), raw.vt.value)?;
195
196    let mut rec = SvdRec {
197        d,
198        u,
199        s,
200        vt,
201        // LAS2 has no centering mode, so this is the plain Frobenius norm.
202        total_squared_norm: T::from_f64_val(crate::matrix::total_squared_norm(a, None)),
203        diagnostics: Diagnostics {
204            algorithm: Algorithm::Las2,
205            non_zero: a.nnz(),
206            dimensions,
207            significant_values: raw.nsig,
208            transposed,
209            random_seed,
210            matvecs: wrk.matvecs.get(),
211            detail: Detail::Lanczos {
212                iterations,
213                lanczos_steps: steps + 1,
214                ritz_values_stabilized: neig,
215                end_interval: *end_interval,
216                kappa,
217            },
218        },
219    };
220    sort_descending(&mut rec);
221    Ok(rec)
222}
223
224/// Reorder a decomposition so singular values descend, permuting `u` and `vt` with
225/// them. LAS2 produces them in ascending Ritz-value order internally.
226fn sort_descending<T: SvdFloat>(rec: &mut SvdRec<T>) {
227    let d = rec.d;
228    let mut order: Vec<usize> = (0..d).collect();
229    order.sort_by(|&i, &j| {
230        rec.s[j]
231            .partial_cmp(&rec.s[i])
232            .unwrap_or(std::cmp::Ordering::Equal)
233    });
234    if order.iter().enumerate().all(|(i, &o)| i == o) {
235        return;
236    }
237    let s = Array1::from_iter(order.iter().map(|&i| rec.s[i]));
238    let u = rec.u.select(ndarray::Axis(1), &order);
239    let vt = rec.vt.select(ndarray::Axis(0), &order);
240    rec.s = s;
241    rec.u = u;
242    rec.vt = vt;
243}
244
245/// Sparsity-derived tolerances and iteration caps.
246///
247/// SVDLIBC used fixed values; very sparse operands need looser tolerances and more QL
248/// sweeps to converge, so these scale with fill.
249struct Tuning<T> {
250    /// Tolerance floor used in place of raw machine epsilon.
251    eps: T,
252    /// Iteration cap for the tridiagonal QL kernels.
253    ql_iterations: usize,
254    /// Extra Lanczos steps granted per restart on very sparse inputs.
255    extra_steps: usize,
256    /// Multiplier applied to `kappa` when deciding significance.
257    kappa_scale: T,
258}
259
260impl<T: SvdFloat> Tuning<T> {
261    fn for_matrix(nnz: usize, rows: usize, cols: usize) -> Self {
262        let denom = (rows as f64) * (cols as f64);
263        let sparsity = if denom > 0.0 {
264            1.0 - (nnz as f64 / denom)
265        } else {
266            0.0
267        };
268        let eps = T::eps();
269        let (eps_scale, ql_iterations, extra_steps, kappa_scale) = if sparsity > 0.999 {
270            (100.0, 500, 5, 10.0)
271        } else if sparsity > 0.99 {
272            (100.0, 300, 5, 10.0)
273        } else if sparsity > 0.9 {
274            (10.0, 200, 0, 1.0)
275        } else {
276            (1.0, MAX_QL_ITERATIONS, 0, 1.0)
277        };
278        Self {
279            eps: eps * T::from_f64_val(eps_scale),
280            ql_iterations,
281            extra_steps,
282            kappa_scale: T::from_f64_val(kappa_scale),
283        }
284    }
285}
286
287/// Retained Lanczos vectors.
288///
289/// `storq` holds the Lanczos basis (offset by [`MAXLL`]); `storp` holds the first
290/// [`MAXLL`] vectors used for the initial reorthogonalization.
291struct Store<T> {
292    n: usize,
293    vecs: Vec<Vec<T>>,
294}
295
296impl<T: SvdFloat> Store<T> {
297    fn new(n: usize) -> Self {
298        Self { n, vecs: vec![] }
299    }
300    fn storq(&mut self, idx: usize, v: &[T]) {
301        while idx + MAXLL >= self.vecs.len() {
302            self.vecs.push(vec![T::zero(); self.n]);
303        }
304        self.vecs[idx + MAXLL].copy_from_slice(v);
305    }
306    fn storp(&mut self, idx: usize, v: &[T]) {
307        while idx >= self.vecs.len() {
308            self.vecs.push(vec![T::zero(); self.n]);
309        }
310        self.vecs[idx].copy_from_slice(v);
311    }
312    fn retrq(&self, idx: usize) -> &[T] {
313        &self.vecs[idx + MAXLL]
314    }
315    fn retrp(&self, idx: usize) -> &[T] {
316        &self.vecs[idx]
317    }
318}
319
320struct WorkSpace<T> {
321    nrows: usize,
322    ncols: usize,
323    transposed: bool,
324    w0: Vec<T>,
325    w1: Vec<T>,
326    w2: Vec<T>,
327    w3: Vec<T>,
328    w4: Vec<T>,
329    w5: Vec<T>,
330    /// Diagonal of the tridiagonal matrix T.
331    alf: Vec<T>,
332    /// Orthogonality estimate at step j.
333    eta: Vec<T>,
334    /// Orthogonality estimate at step j-1.
335    oldeta: Vec<T>,
336    /// Off-diagonal of T.
337    bet: Vec<T>,
338    /// Error bounds.
339    bnd: Vec<T>,
340    /// Ritz values.
341    ritz: Vec<T>,
342    temp: Vec<T>,
343    /// Sparse products issued, for diagnostics. The Lanczos recurrence is serial, so a
344    /// `Cell` suffices — the parallelism lives inside each product.
345    matvecs: Cell<usize>,
346    /// Set when a QL sweep hit its iteration cap and fell back to best estimates.
347    ql_degraded: Cell<bool>,
348}
349
350impl<T: SvdFloat> WorkSpace<T> {
351    fn new(nrows: usize, ncols: usize, transposed: bool, iterations: usize) -> Self {
352        Self {
353            nrows,
354            ncols,
355            transposed,
356            w0: vec![T::zero(); ncols],
357            w1: vec![T::zero(); ncols],
358            w2: vec![T::zero(); ncols],
359            w3: vec![T::zero(); ncols],
360            w4: vec![T::zero(); ncols],
361            w5: vec![T::zero(); ncols],
362            alf: vec![T::zero(); iterations],
363            eta: vec![T::zero(); iterations],
364            oldeta: vec![T::zero(); iterations],
365            bet: vec![T::zero(); 1 + iterations],
366            ritz: vec![T::zero(); 1 + iterations],
367            bnd: vec![<T as num_traits::Bounded>::max_value(); 1 + iterations],
368            temp: vec![T::zero(); nrows],
369            matvecs: Cell::new(0),
370            ql_degraded: Cell::new(false),
371        }
372    }
373}
374
375/// Row-major dense matrix; rows are consecutive.
376struct DMat<T> {
377    cols: usize,
378    value: Vec<T>,
379}
380
381struct RawRec<T> {
382    d: usize,
383    nsig: usize,
384    ut: DMat<T>,
385    s: Vec<T>,
386    vt: DMat<T>,
387}
388
389#[inline]
390fn close<T: SvdFloat>(a: T, b: T) -> bool {
391    T::close(a, b)
392}
393
394/// Sort `keys` ascending, applying the same permutation to `vals`.
395///
396/// Replaces SVDLIBC's insertion sort, which was quadratic in the Lanczos step count.
397/// A stable sort keeps the tie ordering the original relied on.
398fn sort_pair<T: SvdFloat>(n: usize, keys: &mut [T], vals: &mut [T]) {
399    let mut order: Vec<usize> = (0..n).collect();
400    order.sort_by(|&i, &j| {
401        keys[i]
402            .partial_cmp(&keys[j])
403            .unwrap_or(std::cmp::Ordering::Equal)
404    });
405    let sk: Vec<T> = order.iter().map(|&i| keys[i]).collect();
406    let sv: Vec<T> = order.iter().map(|&i| vals[i]).collect();
407    keys[..n].copy_from_slice(&sk);
408    vals[..n].copy_from_slice(&sv);
409}
410
411/// `y = Aᵀ(Ax)`, using `temp` as the intermediate.
412fn svd_opb<T: SvdFloat, M: SparseMat<T>>(
413    a: &M,
414    x: &[T],
415    y: &mut [T],
416    temp: &mut [T],
417    transposed: bool,
418    matvecs: &Cell<usize>,
419) {
420    a.mul_vec(x, temp, transposed);
421    a.mul_vec(temp, y, !transposed);
422    matvecs.set(matvecs.get() + 2);
423}
424
425fn daxpy<T: SvdFloat>(da: T, x: &[T], y: &mut [T]) {
426    if x.len() < 1024 {
427        for (yv, &xv) in y.iter_mut().zip(x.iter()) {
428            *yv += da * xv;
429        }
430    } else {
431        y.par_iter_mut()
432            .zip(x.par_iter())
433            .for_each(|(yv, &xv)| *yv += da * xv);
434    }
435}
436
437fn ddot<T: SvdFloat>(x: &[T], y: &[T]) -> T {
438    if x.len() < 1024 {
439        x.iter().zip(y).map(|(&a, &b)| a * b).sum()
440    } else {
441        x.par_iter().zip(y.par_iter()).map(|(&a, &b)| a * b).sum()
442    }
443}
444
445fn norm<T: SvdFloat>(x: &[T]) -> T {
446    ddot(x, x).sqrt()
447}
448
449fn datx<T: SvdFloat>(d: T, x: &[T], y: &mut [T]) {
450    for (yv, &xv) in y.iter_mut().zip(x.iter()) {
451        *yv = d * xv;
452    }
453}
454
455fn dscal<T: SvdFloat>(d: T, x: &mut [T]) {
456    if x.len() < 1024 {
457        for v in x.iter_mut() {
458            *v *= d;
459        }
460    } else {
461        x.par_iter_mut().for_each(|v| *v *= d);
462    }
463}
464
465/// Copy `n` elements of `x` into `y` starting at `offset`, reversing their order.
466fn dcopy_rev<T: SvdFloat>(n: usize, offset: usize, x: &[T], y: &mut [T]) {
467    if n > 0 {
468        let start = n - 1;
469        for i in 0..n {
470            y[offset + start - i] = x[offset + i];
471        }
472    }
473}
474
475/// Index of the element with the largest magnitude.
476fn idamax<T: SvdFloat>(n: usize, x: &[T]) -> usize {
477    debug_assert!(n > 0);
478    let mut imax = 0;
479    for i in 1..n {
480        if Float::abs(x[i]) > Float::abs(x[imax]) {
481            imax = i;
482        }
483    }
484    imax
485}
486
487/// `|a|` if `b >= 0`, else `-|a|`.
488fn fsign<T: SvdFloat>(a: T, b: T) -> T {
489    if (a >= T::zero()) == (b >= T::zero()) {
490        a
491    } else {
492        -a
493    }
494}
495
496/// `sqrt(a² + b²)` without intermediate overflow.
497fn pythag<T: SvdFloat>(a: T, b: T) -> T {
498    let n = Float::max(Float::abs(a), Float::abs(b));
499    if n <= T::zero() {
500        return T::zero();
501    }
502    let four = T::from_f64_val(4.0);
503    let two = T::from_f64_val(2.0);
504    let mut p = n;
505    let mut r = Float::powi(Float::min(Float::abs(a), Float::abs(b)) / p, 2);
506    let mut t = four + r;
507    // The convergence test is `t == 4`, which a NaN never satisfies — an unbounded loop
508    // here would hang the process. The iteration converges quadratically, so a handful
509    // of steps is ample and the cap only ever fires on a poisoned input.
510    let mut guard = 0usize;
511    while !close(t, four) && guard < 64 {
512        guard += 1;
513        let s = r / t;
514        let u = T::one() + two * s;
515        p *= u;
516        r = Float::powi(s / u, 2);
517        t = four + r;
518    }
519    p
520}
521
522/// Implicit QL for the eigenvalues of a symmetric tridiagonal matrix, tracking the
523/// first components of the eigenvectors in `bnd`.
524///
525/// On hitting the iteration cap this widens the affected error bounds and continues
526/// rather than failing, matching 1.x behaviour, and reports it through `degraded`.
527fn imtqlb<T: SvdFloat>(
528    n: usize,
529    d: &mut [T],
530    e: &mut [T],
531    bnd: &mut [T],
532    max_iter: usize,
533    degraded: &Cell<bool>,
534) {
535    if n == 1 {
536        return;
537    }
538    let size_factor = T::from_f64_val((n as f64).sqrt());
539    bnd[0] = T::one();
540    let last = n - 1;
541    for i in 1..=last {
542        bnd[i] = T::zero();
543        e[i - 1] = e[i];
544    }
545    e[last] = T::zero();
546
547    let mut i = 0;
548    for l in 0..=last {
549        let mut iteration = 0;
550
551        while iteration <= max_iter {
552            let mut m = l;
553            while m < n {
554                if m == last {
555                    break;
556                }
557                let test = Float::abs(d[m]) + Float::abs(d[m + 1]);
558                let tol =
559                    T::eps() * T::from_f64_val(100.0) * Float::max(test, T::one()) * size_factor;
560                if Float::abs(e[m]) <= tol {
561                    break;
562                }
563                m += 1;
564            }
565
566            // The shift origin and the tracked eigenvector component must be re-read
567            // from the *current* d and bnd on every sweep — EISPACK IMTQL1 assigns
568            // `p = d(l)` at label 120, inside the iteration loop. 1.x hoisted both out
569            // of the loop, so after the first sweep every subsequent eigenvalue was
570            // computed from a stale shift. That is what produced the "imtqlb had some
571            // convergence issues" warnings and the garbage Ritz values behind them.
572            let mut p = d[l];
573            let mut f = bnd[l];
574
575            if m == l {
576                // Insert this eigenvalue into the already-ordered prefix.
577                let mut exchange = true;
578                if l > 0 {
579                    i = l;
580                    while i >= 1 && exchange {
581                        if p < d[i - 1] {
582                            d[i] = d[i - 1];
583                            bnd[i] = bnd[i - 1];
584                            i -= 1;
585                        } else {
586                            exchange = false;
587                        }
588                    }
589                }
590                if exchange {
591                    i = 0;
592                }
593                d[i] = p;
594                bnd[i] = f;
595                break;
596            }
597
598            if iteration == max_iter {
599                degraded.set(true);
600                for b in bnd.iter_mut().take(m + 1).skip(l) {
601                    *b = Float::max(*b, T::from_f64_val(0.1));
602                }
603                e[l] = T::zero();
604                break;
605            }
606            iteration += 1;
607
608            let two = T::from_f64_val(2.0);
609            let mut g = (d[l + 1] - p) / (two * e[l]);
610            let mut r = pythag(g, T::one());
611            g = d[m] - p + e[l] / (g + fsign(r, g));
612            let mut s = T::one();
613            let mut c = T::one();
614            p = T::zero();
615
616            debug_assert!(m > 0);
617            i = m - 1;
618            let mut underflow = false;
619            while !underflow && i >= l {
620                f = s * e[i];
621                let b = c * e[i];
622                r = pythag(f, g);
623                e[i + 1] = r;
624
625                if r < T::eps() * T::from_f64_val(1000.0) * (Float::abs(f) + Float::abs(g)) {
626                    underflow = true;
627                    break;
628                }
629                if Float::abs(r) < T::eps() * T::from_f64_val(100.0) {
630                    r = T::eps() * T::from_f64_val(100.0) * fsign(T::one(), r);
631                }
632
633                s = f / r;
634                c = g / r;
635                g = d[i + 1] - p;
636                r = (d[i] - g) * s + two * c * b;
637                p = s * r;
638                d[i + 1] = g + p;
639                g = c * r - b;
640                f = bnd[i + 1];
641                bnd[i + 1] = s * bnd[i] + c * f;
642                bnd[i] = c * bnd[i] - s * f;
643                if i == 0 {
644                    break;
645                }
646                i -= 1;
647            }
648            if underflow {
649                d[i + 1] -= p;
650            } else {
651                d[l] -= p;
652                e[l] = g;
653            }
654            e[m] = T::zero();
655        }
656    }
657}
658
659/// Implicit QL for eigenvalues *and* eigenvectors of a symmetric tridiagonal matrix.
660fn imtql2<T: SvdFloat>(
661    nm: usize,
662    n: usize,
663    d: &mut [T],
664    e: &mut [T],
665    z: &mut [T],
666    max_iter: usize,
667) -> Result<()> {
668    if n == 1 {
669        return Ok(());
670    }
671    let two = T::from_f64_val(2.0);
672    let last = n - 1;
673    for i in 1..n {
674        e[i - 1] = e[i];
675    }
676    e[last] = T::zero();
677
678    let nnm = n * nm;
679    for l in 0..n {
680        let mut iteration = 0;
681        while iteration <= max_iter {
682            let mut m = l;
683            while m < n {
684                if m == last {
685                    break;
686                }
687                let test = Float::abs(d[m]) + Float::abs(d[m + 1]);
688                if close(test, test + Float::abs(e[m])) {
689                    break;
690                }
691                m += 1;
692            }
693            if m == l {
694                break;
695            }
696            if iteration == max_iter {
697                return Err(SvdLibError::NoConvergence {
698                    stage: "imtql2",
699                    iterations: max_iter,
700                });
701            }
702            iteration += 1;
703
704            let mut g = (d[l + 1] - d[l]) / (two * e[l]);
705            let mut r = pythag(g, T::one());
706            g = d[m] - d[l] + e[l] / (g + fsign(r, g));
707            let mut s = T::one();
708            let mut c = T::one();
709            let mut p = T::zero();
710
711            debug_assert!(m > 0);
712            let mut i = m - 1;
713            let mut underflow = false;
714            while !underflow && i >= l {
715                let mut f = s * e[i];
716                let b = c * e[i];
717                r = pythag(f, g);
718                e[i + 1] = r;
719                if close(r, T::zero()) {
720                    underflow = true;
721                } else {
722                    s = f / r;
723                    c = g / r;
724                    g = d[i + 1] - p;
725                    r = (d[i] - g) * s + two * c * b;
726                    p = s * r;
727                    d[i + 1] = g + p;
728                    g = c * r - b;
729                    for k in (0..nnm).step_by(n) {
730                        let index = k + i;
731                        f = z[index + 1];
732                        z[index + 1] = s * z[index] + c * f;
733                        z[index] = c * z[index] - s * f;
734                    }
735                    if i == 0 {
736                        break;
737                    }
738                    i -= 1;
739                }
740            }
741            if underflow {
742                d[i + 1] -= p;
743            } else {
744                d[l] -= p;
745                e[l] = g;
746            }
747            e[m] = T::zero();
748        }
749    }
750
751    // Order eigenvalues ascending, carrying the eigenvectors along.
752    for l in 1..n {
753        let i = l - 1;
754        let mut k = i;
755        let mut p = d[i];
756        for (j, item) in d.iter().enumerate().take(n).skip(l) {
757            if *item < p {
758                k = j;
759                p = *item;
760            }
761        }
762        if k != i {
763            d[k] = d[i];
764            d[i] = p;
765            for j in (0..nnm).step_by(n) {
766                z.swap(j + i, j + k);
767            }
768        }
769    }
770    Ok(())
771}
772
773/// Produce a starting vector in the range of `AᵀA`, orthogonal to the basis so far.
774fn startv<T: SvdFloat, M: SparseMat<T>>(
775    a: &M,
776    wrk: &mut WorkSpace<T>,
777    step: usize,
778    store: &Store<T>,
779    random_seed: u64,
780) -> Result<T> {
781    let mut rnm2 = ddot(&wrk.w0, &wrk.w0);
782    for id in 0..3 {
783        if id > 0 || step > 0 || close(rnm2, T::zero()) {
784            let mut bytes = [0u8; 32];
785            for (i, b) in random_seed.to_le_bytes().iter().enumerate() {
786                bytes[i] = *b;
787            }
788            let mut seeded = StdRng::from_seed(bytes);
789            for val in wrk.w0.iter_mut() {
790                *val = T::from_f64_val(seeded.random_range(-1.0..1.0));
791            }
792        }
793        wrk.w3.copy_from_slice(&wrk.w0);
794        svd_opb(
795            a,
796            &wrk.w3,
797            &mut wrk.w0,
798            &mut wrk.temp,
799            wrk.transposed,
800            &wrk.matvecs,
801        );
802        wrk.w3.copy_from_slice(&wrk.w0);
803        rnm2 = ddot(&wrk.w3, &wrk.w3);
804        if rnm2 > T::zero() {
805            break;
806        }
807    }
808
809    if rnm2 <= T::zero() {
810        return Err(SvdLibError::failed(
811            "startv",
812            format!("could not find a starting vector in range (rnm2 = {rnm2:?})"),
813        ));
814    }
815
816    if step > 0 {
817        for i in 0..step {
818            let v = store.retrq(i);
819            daxpy(-ddot(&wrk.w3, v), v, &mut wrk.w0);
820        }
821        // Keep q[step] orthogonal to q[step-1].
822        let t = -ddot(&wrk.w4, &wrk.w0);
823        let w2 = std::mem::take(&mut wrk.w2);
824        daxpy(t, &w2, &mut wrk.w0);
825        wrk.w2 = w2;
826        wrk.w3.copy_from_slice(&wrk.w0);
827        rnm2 = match ddot(&wrk.w3, &wrk.w3) {
828            dot if dot <= T::eps() * rnm2 => T::zero(),
829            dot => dot,
830        };
831    }
832    Ok(rnm2.sqrt())
833}
834
835/// The first Lanczos step; returns `(rnm, tol)`.
836fn stpone<T: SvdFloat, M: SparseMat<T>>(
837    a: &M,
838    wrk: &mut WorkSpace<T>,
839    store: &Store<T>,
840    random_seed: u64,
841) -> Result<(T, T)> {
842    let mut rnm = startv(a, wrk, 0, store, random_seed)?;
843    if close(rnm, T::zero()) {
844        return Err(SvdLibError::failed(
845            "stpone",
846            "starting vector has zero norm",
847        ));
848    }
849
850    datx(Float::recip(rnm), &wrk.w0, &mut wrk.w1);
851    dscal(Float::recip(rnm), &mut wrk.w3);
852
853    svd_opb(
854        a,
855        &wrk.w3,
856        &mut wrk.w0,
857        &mut wrk.temp,
858        wrk.transposed,
859        &wrk.matvecs,
860    );
861    wrk.alf[0] = ddot(&wrk.w0, &wrk.w3);
862    let alf0 = wrk.alf[0];
863    let w1 = std::mem::take(&mut wrk.w1);
864    daxpy(-alf0, &w1, &mut wrk.w0);
865    let t = ddot(&wrk.w0, &wrk.w3);
866    wrk.alf[0] += t;
867    daxpy(-t, &w1, &mut wrk.w0);
868    wrk.w1 = w1;
869    wrk.w4.copy_from_slice(&wrk.w0);
870    rnm = norm(&wrk.w4);
871    let anorm = rnm + Float::abs(wrk.alf[0]);
872    Ok((rnm, T::eps().sqrt() * anorm))
873}
874
875#[allow(clippy::too_many_arguments)]
876fn lanczos_step<T: SvdFloat, M: SparseMat<T>>(
877    a: &M,
878    wrk: &mut WorkSpace<T>,
879    first: usize,
880    last: usize,
881    ll: &mut usize,
882    enough: &mut bool,
883    rnm: &mut T,
884    tol: &mut T,
885    store: &mut Store<T>,
886) -> Result<usize> {
887    let eps1 = T::eps() * T::from_f64_val(wrk.ncols as f64).sqrt();
888    let mut j = first;
889    let four = T::from_f64_val(4.0);
890
891    while j < last {
892        mem::swap(&mut wrk.w1, &mut wrk.w2);
893        mem::swap(&mut wrk.w3, &mut wrk.w4);
894
895        store.storq(j - 1, &wrk.w2);
896        if j - 1 < MAXLL {
897            store.storp(j - 1, &wrk.w4);
898        }
899        wrk.bet[j] = *rnm;
900
901        // Restart if an invariant subspace turned up.
902        if close(*rnm, T::zero()) {
903            *rnm = startv(a, wrk, j, store, 0)?;
904            if close(*rnm, T::zero()) {
905                *enough = true;
906            }
907        }
908        if *enough {
909            mem::swap(&mut wrk.w1, &mut wrk.w2);
910            break;
911        }
912
913        datx(Float::recip(*rnm), &wrk.w0, &mut wrk.w1);
914        dscal(Float::recip(*rnm), &mut wrk.w3);
915        svd_opb(
916            a,
917            &wrk.w3,
918            &mut wrk.w0,
919            &mut wrk.temp,
920            wrk.transposed,
921            &wrk.matvecs,
922        );
923        let rnm_v = *rnm;
924        let w2 = std::mem::take(&mut wrk.w2);
925        daxpy(-rnm_v, &w2, &mut wrk.w0);
926        wrk.w2 = w2;
927        wrk.alf[j] = ddot(&wrk.w0, &wrk.w3);
928        let alfj = wrk.alf[j];
929        let w1 = std::mem::take(&mut wrk.w1);
930        daxpy(-alfj, &w1, &mut wrk.w0);
931        wrk.w1 = w1;
932
933        // Reorthogonalize against the first few Lanczos vectors.
934        if j <= MAXLL && Float::abs(wrk.alf[j - 1]) > four * Float::abs(wrk.alf[j]) {
935            *ll = j;
936        }
937        for i in 0..(j - 1).min(*ll) {
938            let t = ddot(store.retrp(i), &wrk.w0);
939            daxpy(-t, store.retrq(i), &mut wrk.w0);
940            wrk.eta[i] = eps1;
941            wrk.oldeta[i] = eps1;
942        }
943
944        // Extended local reorthogonalization.
945        let t = ddot(&wrk.w0, &wrk.w4);
946        let w2 = std::mem::take(&mut wrk.w2);
947        daxpy(-t, &w2, &mut wrk.w0);
948        wrk.w2 = w2;
949        if wrk.bet[j] > T::zero() {
950            wrk.bet[j] += t;
951        }
952        let t = ddot(&wrk.w0, &wrk.w3);
953        let w1 = std::mem::take(&mut wrk.w1);
954        daxpy(-t, &w1, &mut wrk.w0);
955        wrk.w1 = w1;
956        wrk.alf[j] += t;
957        wrk.w4.copy_from_slice(&wrk.w0);
958        *rnm = norm(&wrk.w4);
959        let anorm = wrk.bet[j] + Float::abs(wrk.alf[j]) + *rnm;
960        *tol = T::eps().sqrt() * anorm;
961
962        ortbnd(wrk, j, *rnm, eps1);
963        purge(wrk.ncols, *ll, wrk, j, rnm, *tol, store);
964        if *rnm <= *tol {
965            *rnm = T::zero();
966        }
967        j += 1;
968    }
969    Ok(j)
970}
971
972/// Restore orthogonality once the estimates say it has been lost.
973fn purge<T: SvdFloat>(
974    n: usize,
975    ll: usize,
976    wrk: &mut WorkSpace<T>,
977    step: usize,
978    rnm: &mut T,
979    tol: T,
980    store: &Store<T>,
981) {
982    if step < ll + 2 {
983        return;
984    }
985    let reps = T::eps().sqrt();
986    let eps1 = T::eps() * T::from_f64_val(n as f64).sqrt();
987
988    let k = idamax(step - (ll + 1), &wrk.eta) + ll;
989    if Float::abs(wrk.eta[k]) > reps {
990        let reps1 = eps1 / reps;
991        let mut iteration = 0;
992        let mut flag = true;
993        while iteration < 2 && flag {
994            if *rnm > tol {
995                let mut tq = T::zero();
996                let mut tr = T::zero();
997                for i in ll..step {
998                    let v = store.retrq(i);
999                    let t = ddot(v, &wrk.w3);
1000                    tq += Float::abs(t);
1001                    daxpy(-t, v, &mut wrk.w1);
1002                    let t = ddot(v, &wrk.w4);
1003                    tr += Float::abs(t);
1004                    daxpy(-t, v, &mut wrk.w0);
1005                }
1006                wrk.w3.copy_from_slice(&wrk.w1);
1007                let t = ddot(&wrk.w0, &wrk.w3);
1008                tr += Float::abs(t);
1009                let w1 = std::mem::take(&mut wrk.w1);
1010                daxpy(-t, &w1, &mut wrk.w0);
1011                wrk.w1 = w1;
1012                wrk.w4.copy_from_slice(&wrk.w0);
1013                *rnm = norm(&wrk.w4);
1014                if tq <= reps1 && tr <= *rnm * reps1 {
1015                    flag = false;
1016                }
1017            }
1018            iteration += 1;
1019        }
1020        for i in ll..=step {
1021            wrk.eta[i] = eps1;
1022            wrk.oldeta[i] = eps1;
1023        }
1024    }
1025}
1026
1027/// Update the running estimates of basis orthogonality.
1028fn ortbnd<T: SvdFloat>(wrk: &mut WorkSpace<T>, step: usize, rnm: T, eps1: T) {
1029    if step < 1 {
1030        return;
1031    }
1032    if !close(rnm, T::zero()) && step > 1 {
1033        wrk.oldeta[0] = (wrk.bet[1] * wrk.eta[1] + (wrk.alf[0] - wrk.alf[step]) * wrk.eta[0]
1034            - wrk.bet[step] * wrk.oldeta[0])
1035            / rnm
1036            + eps1;
1037        if step > 2 {
1038            for i in 1..=step - 2 {
1039                wrk.oldeta[i] = (wrk.bet[i + 1] * wrk.eta[i + 1]
1040                    + (wrk.alf[i] - wrk.alf[step]) * wrk.eta[i]
1041                    + wrk.bet[i] * wrk.eta[i - 1]
1042                    - wrk.bet[step] * wrk.oldeta[i])
1043                    / rnm
1044                    + eps1;
1045            }
1046        }
1047    }
1048    wrk.oldeta[step - 1] = eps1;
1049    mem::swap(&mut wrk.oldeta, &mut wrk.eta);
1050    wrk.eta[step] = eps1;
1051}
1052
1053/// Tighten error bounds and count how many Ritz values have stabilized.
1054fn error_bound<T: SvdFloat>(
1055    enough: &mut bool,
1056    endl: T,
1057    endr: T,
1058    ritz: &mut [T],
1059    bnd: &mut [T],
1060    step: usize,
1061    tol: T,
1062) -> usize {
1063    debug_assert!(step > 0);
1064    let mid = idamax(step + 1, bnd);
1065    let sixteen = T::from_f64_val(16.0);
1066
1067    // Fold bounds together for Ritz values that are nearly coincident.
1068    let mut i = ((step + 1) + (step - 1)) / 2;
1069    while i > mid + 1 {
1070        if Float::abs(ritz[i - 1] - ritz[i]) < T::eps34() * Float::abs(ritz[i])
1071            && bnd[i] > tol
1072            && bnd[i - 1] > tol
1073        {
1074            bnd[i - 1] = (Float::powi(bnd[i], 2) + Float::powi(bnd[i - 1], 2)).sqrt();
1075            bnd[i] = T::zero();
1076        }
1077        i -= 1;
1078    }
1079    let mut i = ((step + 1) - (step - 1)) / 2;
1080    while i + 1 < mid {
1081        if Float::abs(ritz[i + 1] - ritz[i]) < T::eps34() * Float::abs(ritz[i])
1082            && bnd[i] > tol
1083            && bnd[i + 1] > tol
1084        {
1085            bnd[i + 1] = (Float::powi(bnd[i], 2) + Float::powi(bnd[i + 1], 2)).sqrt();
1086            bnd[i] = T::zero();
1087        }
1088        i += 1;
1089    }
1090
1091    let mut neig = 0;
1092    let mut gapl = ritz[step] - ritz[0];
1093    for i in 0..=step {
1094        let mut gap = gapl;
1095        if i < step {
1096            gapl = ritz[i + 1] - ritz[i];
1097        }
1098        gap = Float::min(gap, gapl);
1099        if gap > bnd[i] {
1100            bnd[i] *= bnd[i] / gap;
1101        }
1102        if bnd[i] <= sixteen * T::eps() * Float::abs(ritz[i]) {
1103            neig += 1;
1104            if !*enough {
1105                *enough = endl < ritz[i] && ritz[i] < endr;
1106            }
1107        }
1108    }
1109    neig
1110}
1111
1112/// Recover singular triplets from the converged Lanczos basis.
1113#[allow(clippy::too_many_arguments)]
1114fn ritvec<T: SvdFloat, M: SparseMat<T>>(
1115    a: &M,
1116    dimensions: usize,
1117    kappa: T,
1118    wrk: &mut WorkSpace<T>,
1119    steps: usize,
1120    neig: usize,
1121    store: &mut Store<T>,
1122    tuning: &Tuning<T>,
1123) -> Result<RawRec<T>> {
1124    let js = steps + 1;
1125    let jsq = js * js;
1126    let adaptive_eps = tuning.eps;
1127
1128    let mut s = vec![T::zero(); jsq];
1129    for i in (0..jsq).step_by(js + 1) {
1130        s[i] = T::one();
1131    }
1132
1133    let mut eigenvalues = vec![T::zero(); wrk.ncols.max(js)];
1134    dcopy_rev(js, 0, &wrk.alf, &mut eigenvalues);
1135    dcopy_rev(steps, 1, &wrk.bet, &mut wrk.w5);
1136
1137    // On return `eigenvalues` is ascending and `s` holds the matching eigenvectors.
1138    imtql2(
1139        js,
1140        js,
1141        &mut eigenvalues,
1142        &mut wrk.w5,
1143        &mut s,
1144        tuning.ql_iterations,
1145    )?;
1146
1147    let max_eigenvalue = eigenvalues
1148        .iter()
1149        .take(js)
1150        .fold(T::zero(), |mx, &v| Float::max(mx, Float::abs(v)));
1151    let adaptive_kappa = kappa * tuning.kappa_scale;
1152
1153    let store_vectors: Vec<&[T]> = (0..js).map(|i| store.retrq(i)).collect();
1154
1155    let significant: Vec<usize> = (0..js)
1156        .filter(|&k| {
1157            let bound =
1158                adaptive_kappa * Float::max(Float::abs(wrk.ritz[k]), max_eigenvalue * adaptive_eps);
1159            wrk.bnd[k] <= bound && k + 1 > js - neig
1160        })
1161        .collect();
1162    let nsig = significant.len();
1163
1164    let d = dimensions.min(nsig);
1165    if d == 0 {
1166        return Err(SvdLibError::failed(
1167            "ritvec",
1168            "no singular values met the significance threshold; \
1169             try more iterations or a larger kappa",
1170        ));
1171    }
1172
1173    // `imtql2` and `lanso` both order Ritz values ascending, so the *largest* `d` are
1174    // the tail of `significant`. 1.x took the leading `d` instead, which silently
1175    // returned the smallest converged triplets whenever more converged than were
1176    // requested — on `diag(40..1)` that reported the 10th-largest singular value as
1177    // the largest. Keep the tail, then restore ascending order within it.
1178    let keep: Vec<usize> = significant[nsig - d..].to_vec();
1179
1180    let mut vt_vectors: Vec<(usize, Vec<T>)> = keep
1181        .into_par_iter()
1182        .map(|k| {
1183            let mut vec = vec![T::zero(); wrk.ncols];
1184            for (i, sv) in store_vectors.iter().enumerate().take(js) {
1185                let coeff = s[k * js + i];
1186                if Float::abs(coeff) > adaptive_eps {
1187                    for (dst, &src) in vec.iter_mut().zip(sv.iter()).take(wrk.ncols) {
1188                        *dst += coeff * src;
1189                    }
1190                }
1191            }
1192            (k, vec)
1193        })
1194        .collect();
1195    vt_vectors.sort_by_key(|(k, _)| *k);
1196
1197    let mut vt = DMat {
1198        cols: wrk.ncols,
1199        value: vec![T::zero(); wrk.ncols * d],
1200    };
1201    for (i, (_, vec)) in vt_vectors.into_iter().enumerate() {
1202        let off = i * vt.cols;
1203        vt.value[off..off + vt.cols].copy_from_slice(&vec);
1204    }
1205
1206    let mut ut = DMat {
1207        cols: wrk.nrows,
1208        value: vec![T::zero(); wrk.nrows * d],
1209    };
1210    let mut sv = vec![T::zero(); d];
1211
1212    // Each triplet needs A·v and Aᵀ(A·v); the products are serial because they share
1213    // `wrk.temp`, but each one is internally parallel.
1214    for i in 0..d {
1215        let off = i * vt.cols;
1216        let v = &vt.value[off..off + vt.cols];
1217        let mut abv = vec![T::zero(); vt.cols];
1218        let mut av = vec![T::zero(); wrk.nrows];
1219
1220        svd_opb(a, v, &mut abv, &mut wrk.temp, wrk.transposed, &wrk.matvecs);
1221        a.mul_vec(v, &mut av, wrk.transposed);
1222        wrk.matvecs.set(wrk.matvecs.get() + 1);
1223
1224        let t = ddot(v, &abv);
1225        let sval = Float::max(t, T::zero()).sqrt();
1226        sv[i] = sval;
1227
1228        let scale = T::one() / Float::max(sval, adaptive_eps);
1229        dscal(scale, &mut av);
1230        let uoff = i * ut.cols;
1231        ut.value[uoff..uoff + ut.cols].copy_from_slice(&av);
1232    }
1233
1234    Ok(RawRec {
1235        d,
1236        nsig,
1237        ut,
1238        s: sv,
1239        vt,
1240    })
1241}
1242
1243/// The outer restart loop: run Lanczos steps until enough Ritz values stabilize.
1244#[allow(clippy::too_many_arguments)]
1245fn lanso<T: SvdFloat, M: SparseMat<T>>(
1246    a: &M,
1247    dim: usize,
1248    iterations: usize,
1249    end_interval: &[T; 2],
1250    wrk: &mut WorkSpace<T>,
1251    neig: &mut usize,
1252    store: &mut Store<T>,
1253    random_seed: u64,
1254    tuning: &Tuning<T>,
1255) -> Result<usize> {
1256    let adaptive_eps = tuning.eps;
1257    let (endl, endr) = (end_interval[0], end_interval[1]);
1258
1259    let (mut rnm, mut tol) = stpone(a, wrk, store, random_seed)?;
1260
1261    let eps1 = adaptive_eps * T::from_f64_val(wrk.ncols as f64).sqrt();
1262    wrk.eta[0] = eps1;
1263    wrk.oldeta[0] = eps1;
1264    let mut ll = 0;
1265    let mut first = 1;
1266    let mut last = iterations.min(dim.max(8) + dim);
1267    let mut enough = false;
1268    let mut j = 0;
1269    let mut intro = 0;
1270
1271    while !enough {
1272        if rnm <= tol {
1273            rnm = T::zero();
1274        }
1275
1276        let steps = lanczos_step(
1277            a,
1278            wrk,
1279            first,
1280            last,
1281            &mut ll,
1282            &mut enough,
1283            &mut rnm,
1284            &mut tol,
1285            store,
1286        )?;
1287        j = if enough { steps - 1 } else { last - 1 };
1288
1289        first = j + 1;
1290        wrk.bet[first] = rnm;
1291
1292        // Analyze T one unreduced block at a time.
1293        let mut l = 0;
1294        for _ in 0..j {
1295            if l > j {
1296                break;
1297            }
1298            let mut i = l;
1299            while i <= j {
1300                if Float::abs(wrk.bet[i + 1]) <= adaptive_eps {
1301                    break;
1302                }
1303                i += 1;
1304            }
1305            i = i.min(j);
1306
1307            let sz = i - l;
1308            dcopy_rev(sz + 1, l, &wrk.alf, &mut wrk.ritz);
1309            dcopy_rev(sz, l + 1, &wrk.bet, &mut wrk.w5);
1310
1311            imtqlb(
1312                sz + 1,
1313                &mut wrk.ritz[l..],
1314                &mut wrk.w5[l..],
1315                &mut wrk.bnd[l..],
1316                tuning.ql_iterations,
1317                &wrk.ql_degraded,
1318            );
1319
1320            for m in l..=i {
1321                wrk.bnd[m] = rnm * Float::abs(wrk.bnd[m]);
1322            }
1323            l = i + 1;
1324        }
1325
1326        sort_pair(j + 1, &mut wrk.ritz, &mut wrk.bnd);
1327        *neig = error_bound(&mut enough, endl, endr, &mut wrk.ritz, &mut wrk.bnd, j, tol);
1328
1329        if *neig < dim {
1330            if *neig == 0 {
1331                last = first + 9;
1332                intro = first;
1333            } else {
1334                last =
1335                    first + 3.max(1 + ((j - intro) * (dim - *neig)) / *neig) + tuning.extra_steps;
1336            }
1337            last = last.min(iterations);
1338        } else {
1339            enough = true;
1340        }
1341        enough = enough || first >= iterations;
1342    }
1343    store.storq(j, &wrk.w1);
1344    Ok(j)
1345}
1346
1347#[cfg(test)]
1348#[allow(deprecated)]
1349mod tests {
1350    use super::*;
1351    use crate::matrix::SvdMat;
1352    use crate::testing::{dense_of, gen_lowrank, gen_sparse, reference_singular_values};
1353    use sprs::TriMatI;
1354
1355    /// `diag(n, n-1, ..., 1)` — singular values are known exactly, so this is the
1356    /// least forgiving accuracy probe available.
1357    fn diagonal(n: usize) -> SvdMat<f64> {
1358        let mut t = TriMatI::<f64, u32>::new((n, n));
1359        for i in 0..n {
1360            t.add_triplet(i, i, (n - i) as f64);
1361        }
1362        t.to_csr::<u64>()
1363    }
1364
1365    // ---------------------------------------------------------------------------
1366    // Structural properties. These hold today and guard the port.
1367    // ---------------------------------------------------------------------------
1368
1369    #[test]
1370    fn singular_values_descend() {
1371        let a = gen_sparse(200, 120, 0.05, 3);
1372        let svd = svd_dim_seed(&a, 20, 42).unwrap();
1373        for w in svd.s.to_vec().windows(2) {
1374            assert!(w[0] >= w[1], "not descending: {:?}", svd.s);
1375        }
1376    }
1377
1378    /// `u` must be `m x d` and `vt` `d x n` for every input shape, including the
1379    /// internally-transposed case. 1.x returned `u` as `d x m` from this path while
1380    /// the randomized path returned `m x d`, so `recompose` only worked when square.
1381    #[test]
1382    fn orientation_is_consistent_for_wide_and_tall() {
1383        for (r, c) in [(200usize, 60usize), (60, 200)] {
1384            let a = gen_sparse(r, c, 0.1, 11);
1385            let svd = svd_dim_seed(&a, 10, 42).unwrap();
1386            assert_eq!(svd.u.nrows(), r, "u rows for {r}x{c}");
1387            assert_eq!(svd.u.ncols(), svd.d, "u cols for {r}x{c}");
1388            assert_eq!(svd.vt.nrows(), svd.d, "vt rows for {r}x{c}");
1389            assert_eq!(svd.vt.ncols(), c, "vt cols for {r}x{c}");
1390        }
1391    }
1392
1393    #[test]
1394    fn csc_input_matches_csr() {
1395        let a = gen_sparse(150, 90, 0.08, 5);
1396        let csc = a.to_other_storage();
1397        let from_csr = svd_dim_seed(&a, 12, 42).unwrap();
1398        let from_csc = svd_dim_seed(&csc, 12, 42).unwrap();
1399        for (x, y) in from_csr.s.iter().zip(from_csc.s.iter()) {
1400            approx::assert_relative_eq!(x, y, max_relative = 1e-10);
1401        }
1402    }
1403
1404    #[test]
1405    fn rejects_degenerate_shapes() {
1406        let a = gen_sparse(1, 10, 1.0, 1);
1407        assert!(matches!(
1408            svd_dim_seed(&a, 0, 42),
1409            Err(SvdLibError::InvalidArgument(_))
1410        ));
1411    }
1412
1413    #[test]
1414    fn diagnostics_count_matvecs() {
1415        let a = gen_sparse(100, 60, 0.1, 13);
1416        let svd = svd_dim_seed(&a, 8, 42).unwrap();
1417        assert!(svd.diagnostics.matvecs > 0);
1418        assert_eq!(svd.diagnostics.algorithm, Algorithm::Las2);
1419    }
1420
1421    /// The `imtqlb` shift-origin fix, pinned directly.
1422    ///
1423    /// `imtqlb` (eigenvalues only) and `imtql2` (eigenvalues and vectors) run the same
1424    /// implicit-QL recurrence on the same tridiagonal matrix, so their eigenvalues must
1425    /// agree. Before the fix they diverged wildly — on `diag(40..1)` `imtqlb` returned
1426    /// `39.90, 7.11, 0.019, ...` against `imtql2`'s correct `39.89, 38.96, 37.86, ...`.
1427    #[test]
1428    fn imtqlb_agrees_with_imtql2_on_the_same_tridiagonal() {
1429        // A tridiagonal with well-separated eigenvalues.
1430        let n = 24;
1431        let d0: Vec<f64> = (0..n).map(|i| 2.0 + i as f64).collect();
1432        let e0: Vec<f64> = (0..n).map(|i| 0.5 + 0.1 * (i as f64)).collect();
1433
1434        let mut d_b = d0.clone();
1435        let mut e_b = e0.clone();
1436        let mut bnd = vec![0.0f64; n];
1437        let degraded = Cell::new(false);
1438        imtqlb(
1439            n,
1440            &mut d_b,
1441            &mut e_b,
1442            &mut bnd,
1443            MAX_QL_ITERATIONS,
1444            &degraded,
1445        );
1446        assert!(!degraded.get(), "imtqlb reported degraded convergence");
1447
1448        let mut d_2 = d0.clone();
1449        let mut e_2 = e0.clone();
1450        let mut z = vec![0.0f64; n * n];
1451        for i in (0..n * n).step_by(n + 1) {
1452            z[i] = 1.0;
1453        }
1454        imtql2(n, n, &mut d_2, &mut e_2, &mut z, MAX_QL_ITERATIONS).unwrap();
1455
1456        // Not bit-identical: `imtqlb` deflates on a size-scaled tolerance while
1457        // `imtql2` uses the tighter `test + |e| == test`, so it stops marginally
1458        // earlier. A few ulps of spread is expected; the pre-fix divergence was
1459        // orders of magnitude.
1460        for i in 0..n {
1461            approx::assert_relative_eq!(d_b[i], d_2[i], max_relative = 1e-5);
1462        }
1463    }
1464
1465    // ---------------------------------------------------------------------------
1466    // Accuracy against a dense LAPACK reference.
1467    //
1468    // These are `#[ignore]`d because LAS2 does not currently pass them — the failure
1469    // is inherited from published 1.0.9, not introduced by the sprs port (verified by
1470    // running 1.0.9 on identical fixtures). Two defects are identified so far:
1471    //
1472    //   1. `imtqlb` hoisted the shift origin out of its iteration loop — FIXED.
1473    //   2. `ritvec` reads `s[k*js + i]` (row `k`) while `imtql2` stores eigenvectors
1474    //      as columns; transposing roughly halves the error but does not close it,
1475    //      so at least one further defect remains.
1476    //
1477    // Un-ignore these once LAS2 is repaired, or delete them with the module if LAS2
1478    // is retired in favour of `crate::irlba`.
1479    // ---------------------------------------------------------------------------
1480
1481    fn assert_matches_lapack(name: &str, a: &SvdMat<f64>, dims: usize, tol: f64) {
1482        let want = reference_singular_values(&dense_of(a));
1483        let svd = svd_dim_seed(a, dims, 42).unwrap_or_else(|e| panic!("{name}: {e}"));
1484        for (i, &g) in svd.s.iter().enumerate() {
1485            let rel = (g - want[i]).abs() / want[i].abs().max(1e-30);
1486            assert!(
1487                rel < tol,
1488                "{name}: singular value {i}: got {g:.9e}, LAPACK {:.9e} (rel {rel:.3e})",
1489                want[i]
1490            );
1491        }
1492    }
1493
1494    #[test]
1495    #[ignore = "LAS2 accuracy defect inherited from 1.0.9; see module comment"]
1496    fn exact_on_diagonal_matrix() {
1497        assert_matches_lapack("diagonal_40", &diagonal(40), 10, 1e-8);
1498    }
1499
1500    #[test]
1501    #[ignore = "LAS2 accuracy defect inherited from 1.0.9; see module comment"]
1502    fn agrees_with_dense_reference_lowrank() {
1503        assert_matches_lapack("lowrank_80x50_r8", &gen_lowrank(80, 50, 8, 21), 8, 1e-6);
1504    }
1505
1506    #[test]
1507    #[ignore = "LAS2 accuracy defect inherited from 1.0.9; see module comment"]
1508    fn agrees_with_dense_reference_sparse() {
1509        assert_matches_lapack("sparse_500x40", &gen_sparse(500, 40, 0.10, 7), 10, 1e-6);
1510    }
1511
1512    #[test]
1513    #[ignore = "LAS2 accuracy defect inherited from 1.0.9; see module comment"]
1514    fn recompose_round_trips() {
1515        let a = gen_lowrank(40, 25, 25, 99);
1516        let dense = dense_of(&a);
1517        let svd = svd_dim_seed(&a, 25, 42).unwrap();
1518        let rec = svd.recompose();
1519        let err: f64 = (&rec - &dense).iter().map(|v| v * v).sum::<f64>().sqrt();
1520        let scale: f64 = dense.iter().map(|v| v * v).sum::<f64>().sqrt();
1521        assert!(
1522            err / scale < 1e-8,
1523            "relative reconstruction error {}",
1524            err / scale
1525        );
1526    }
1527
1528    /// Scope report: prints LAS2's error against LAPACK across matrix classes.
1529    /// Not an assertion — a diagnostic for whoever picks up the repair.
1530    #[test]
1531    #[ignore = "diagnostic, run explicitly"]
1532    fn report_accuracy_vs_lapack() {
1533        let cases: Vec<(&str, SvdMat<f64>, usize)> = vec![
1534            ("diagonal_40", diagonal(40), 10),
1535            ("diagonal_40_full", diagonal(40), 40),
1536            ("lowrank_80x50_r8", gen_lowrank(80, 50, 8, 21), 8),
1537            ("lowrank_200x80_r10", gen_lowrank(200, 80, 10, 555), 15),
1538            ("sparse_500x40_d10", gen_sparse(500, 40, 0.10, 7), 10),
1539            ("sparse_200x120_d05", gen_sparse(200, 120, 0.05, 3), 20),
1540        ];
1541        for (name, a, dims) in cases {
1542            let want = reference_singular_values(&dense_of(&a));
1543            match svd_dim_seed(&a, dims, 42) {
1544                Ok(svd) => {
1545                    let got = svd.s.to_vec();
1546                    let n = got.len().min(want.len());
1547                    let worst = (0..n)
1548                        .map(|i| (got[i] - want[i]).abs() / want[i].abs().max(1e-30))
1549                        .fold(0.0f64, f64::max);
1550                    println!(
1551                        "{name:<24} dims={dims:<3} d={:<3} top_rel={:>9.2e} worst_rel={worst:>9.2e}",
1552                        svd.d,
1553                        (got[0] - want[0]).abs() / want[0].abs()
1554                    );
1555                }
1556                Err(e) => println!("{name:<24} dims={dims:<3} ERROR {e}"),
1557            }
1558        }
1559    }
1560}