#![allow(clippy::needless_range_loop)]
use single_svdlib::{irlba, randomized, MaskedCsMat, SparseMat, SvdLibError, SvdMat};
use sprs::TriMatI;
fn from_triplets(rows: usize, cols: usize, t: &[(usize, usize, f64)]) -> SvdMat<f64> {
let mut tri = TriMatI::<f64, u32>::new((rows, cols));
for &(i, j, v) in t {
tri.add_triplet(i, j, v);
}
tri.to_csr::<u64>()
}
fn dense_like(rows: usize, cols: usize, f: impl Fn(usize, usize) -> f64) -> SvdMat<f64> {
let mut tri = TriMatI::<f64, u32>::new((rows, cols));
for i in 0..rows {
for j in 0..cols {
let v = f(i, j);
if v != 0.0 {
tri.add_triplet(i, j, v);
}
}
}
tri.to_csr::<u64>()
}
#[test]
fn all_zero_matrix() {
let a = from_triplets(20, 10, &[]);
assert_eq!(a.nnz(), 0);
match irlba::svd_seed(&a, 3, 42) {
Ok(rec) => {
for &s in rec.s.iter() {
assert!(s.abs() < 1e-10, "expected zero singular values, got {s}");
}
}
Err(SvdLibError::Failed { .. }) => {}
Err(e) => panic!("unexpected error kind: {e}"),
}
match randomized::svd_seed(&a, 3, 42) {
Ok(rec) => {
for &s in rec.s.iter() {
assert!(s.abs() < 1e-10, "expected zero singular values, got {s}");
}
}
Err(SvdLibError::Failed { .. }) | Err(SvdLibError::DenseFactorization { .. }) => {}
Err(e) => panic!("unexpected error kind: {e}"),
}
}
#[test]
fn rank_deficient_beyond_actual_rank() {
let a = dense_like(30, 4, |i, j| (i as f64 + 1.0) * (j as f64 + 1.0));
let rec = irlba::svd_seed(&a, 3, 42).expect("rank-1 matrix, 3 requested");
assert!(rec.s[0] > 1.0, "dominant value should be substantial");
for &s in rec.s.iter().skip(1) {
assert!(s < 1e-8 * rec.s[0], "trailing values should be ~0, got {s}");
}
assert!(rec.s.iter().all(|s| s.is_finite()));
}
#[test]
fn zero_rows_and_columns() {
let a = from_triplets(
10,
8,
&[(3, 1, 2.0), (3, 5, 3.0), (7, 1, 1.0), (7, 5, -4.0)],
);
let rec = irlba::svd_seed(&a, 2, 42).expect("should handle empty rows/cols");
assert!(rec.s.iter().all(|s| s.is_finite() && *s >= 0.0));
assert!(rec.s[0] >= rec.s[1]);
assert!(rec.u.iter().all(|v| v.is_finite()));
assert!(rec.vt.iter().all(|v| v.is_finite()));
}
#[test]
fn duplicated_rows() {
let a = dense_like(40, 12, |i, j| {
let base = i % 4; ((base * 7 + j * 3) % 11) as f64
});
let rec = irlba::svd_seed(&a, 6, 42).expect("duplicated rows");
assert!(rec.s.iter().all(|s| s.is_finite()));
for &s in rec.s.iter().skip(4) {
assert!(s < 1e-8 * rec.s[0], "value beyond true rank was {s}");
}
}
#[test]
fn minimal_shapes() {
let a = from_triplets(2, 2, &[(0, 0, 1.0), (1, 1, 2.0)]);
let rec = irlba::svd_seed(&a, 1, 42).expect("2x2 rank 1");
approx::assert_relative_eq!(rec.s[0], 2.0, max_relative = 1e-10);
let row = from_triplets(1, 5, &[(0, 0, 3.0), (0, 3, 4.0)]);
match irlba::svd_seed(&row, 1, 42) {
Ok(rec) => approx::assert_relative_eq!(rec.s[0], 5.0, max_relative = 1e-8),
Err(e) => panic!("1xN should work or error cleanly, got {e}"),
}
}
#[test]
fn wide_dynamic_range() {
let a = dense_like(50, 20, |i, j| {
if i == 0 {
1e12
} else if i == 1 {
1e-12
} else {
((i * 3 + j) % 7) as f64
}
});
let rec = irlba::svd_seed(&a, 5, 42).expect("wide dynamic range");
assert!(rec.s.iter().all(|s| s.is_finite()), "{:?}", rec.s);
assert!(
rec.s[0] > 1e11,
"dominant scale should survive: {}",
rec.s[0]
);
for w in rec.s.to_vec().windows(2) {
assert!(w[0] >= w[1], "not descending under wide range");
}
}
#[test]
fn nan_input_does_not_hang_or_claim_convergence() {
let a = from_triplets(20, 10, &[(0, 0, 1.0), (1, 1, f64::NAN), (2, 2, 3.0)]);
let cfg = irlba::IrlbaConfig::new(2).seed(42).max_restarts(20);
match irlba::svd_with(&a, &cfg, None) {
Err(_) => {} Ok(rec) => {
let poisoned = rec.s.iter().any(|s| s.is_nan())
|| rec.u.iter().any(|v| v.is_nan())
|| rec.vt.iter().any(|v| v.is_nan());
if poisoned {
match rec.diagnostics.detail {
single_svdlib::Detail::Irlba { converged, .. } => assert!(
!converged,
"NaN propagated into the result but convergence was reported"
),
_ => unreachable!(),
}
}
}
}
}
#[test]
fn randomized_survives_non_finite_input() {
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let a = from_triplets(30, 12, &[(0, 0, 1.0), (1, 1, bad), (2, 2, 3.0)]);
let cfg = randomized::RandomizedConfig::new(3)
.seed(42)
.power_iterations(2);
match randomized::svd_with(&a, &cfg, None) {
Err(_) => {}
Ok(rec) => assert!(
rec.s.iter().all(|s| s.is_finite()),
"returned non-finite singular values for input {bad}"
),
}
let _ = randomized::svd_block_krylov(&a, 3, 2, Some(42));
}
}
#[test]
fn infinity_input_does_not_hang() {
let a = from_triplets(20, 10, &[(0, 0, f64::INFINITY), (1, 1, 2.0)]);
let cfg = irlba::IrlbaConfig::new(2).seed(42).max_restarts(20);
let _ = irlba::svd_with(&a, &cfg, None); }
#[test]
fn maximum_supported_rank() {
let a = dense_like(30, 20, |i, j| ((i * 5 + j * 3) % 13) as f64 + 0.5);
let min_dim = 20;
assert!(
irlba::svd_seed(&a, min_dim - 1, 42).is_ok(),
"rank = min_dim - 1 should be supported"
);
match irlba::svd_seed(&a, min_dim, 42) {
Ok(rec) => assert_eq!(rec.d, min_dim),
Err(SvdLibError::InvalidArgument(msg)) => {
assert!(
msg.contains("work") || msg.contains("rank"),
"unhelpful message at the rank boundary: {msg}"
);
}
Err(e) => panic!("unexpected error kind at rank = min_dim: {e}"),
}
assert!(
randomized::svd_seed(&a, min_dim, 42).is_ok(),
"randomized should support rank = min_dim"
);
}
#[test]
fn exhausted_restart_budget_fails_loudly() {
let a = dense_like(200, 100, |i, j| (((i * 31 + j * 17) % 101) as f64) - 50.0);
let cfg = irlba::IrlbaConfig::new(30)
.seed(42)
.tol(1e-14)
.max_restarts(1);
match irlba::svd_with(&a, &cfg, None) {
Err(SvdLibError::Failed { stage, message }) => {
assert_eq!(stage, "irlba");
assert!(
message.contains("did not converge") && message.contains("residual"),
"error should say what happened and what to do: {message}"
);
}
Err(e) => panic!("wrong error kind: {e}"),
Ok(rec) => assert!(rec.converged(), "returned an unconverged result as Ok"),
}
let lax = cfg.clone().allow_unconverged();
let rec = irlba::svd_with(&a, &lax, None).expect("best effort should be available");
if !rec.converged() {
let resid = rec.max_residual().expect("irlba tracks a residual");
assert!(
resid > 0.0,
"unconverged result must carry a positive residual"
);
}
}
#[test]
fn convergence_is_visible_without_pattern_matching() {
let a = dense_like(120, 40, |i, j| ((i * 3 + j) % 17) as f64);
let rec = irlba::svd_seed(&a, 8, 42).unwrap();
assert!(rec.converged());
assert!(rec.max_residual().is_some());
let r = randomized::svd_seed(&a, 8, 42).unwrap();
assert!(r.converged());
assert!(r.max_residual().is_none());
}
#[test]
fn incoherent_work_size() {
let a = dense_like(60, 30, |i, j| ((i + j) % 5) as f64);
let cfg = irlba::IrlbaConfig::new(10).seed(42).work(3);
match irlba::svd_with(&a, &cfg, None) {
Ok(rec) => assert_eq!(rec.d, 10, "returned a different rank than requested"),
Err(SvdLibError::InvalidArgument(_)) => {}
Err(e) => panic!("unexpected error kind: {e}"),
}
}
#[test]
fn zero_oversampling() {
let a = dense_like(100, 40, |i, j| ((i * 7 + j) % 11) as f64);
let cfg = randomized::RandomizedConfig::new(5).seed(42).oversamples(0);
let rec = randomized::svd_with(&a, &cfg, None).expect("zero oversampling");
assert_eq!(rec.d, 5);
assert!(rec.s.iter().all(|s| s.is_finite()));
}
#[test]
fn empty_column_mask() {
let a = dense_like(30, 10, |i, j| ((i + j) % 3) as f64);
let masked = MaskedCsMat::with_columns(&a, &[]);
assert_eq!(masked.cols(), 0);
assert!(irlba::svd(&masked, 1).is_err());
}
#[test]
fn single_column_mask() {
let a = dense_like(30, 10, |i, j| ((i * 3 + j) % 7) as f64 + 1.0);
let masked = MaskedCsMat::with_columns(&a, &[4]);
assert_eq!(masked.cols(), 1);
match irlba::svd(&masked, 1) {
Ok(rec) => {
assert_eq!(rec.d, 1);
assert!(rec.s[0].is_finite() && rec.s[0] > 0.0);
}
Err(e) => panic!("single-column mask should work, got {e}"),
}
}
#[test]
fn results_are_stable_across_thread_counts() {
let a = dense_like(400, 80, |i, j| (((i * 13 + j * 7) % 23) as f64) - 11.0);
let mut spectra = Vec::new();
for threads in [1usize, 2, 4] {
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(threads)
.build()
.unwrap();
let rec = pool.install(|| irlba::svd_seed(&a, 10, 42).unwrap());
spectra.push(rec.s.to_vec());
}
for k in 1..spectra.len() {
for i in 0..spectra[0].len() {
let rel = (spectra[k][i] - spectra[0][i]).abs() / spectra[0][i].abs().max(1e-30);
assert!(
rel < 1e-8,
"thread count changed singular value {i}: {:.12e} vs {:.12e}",
spectra[k][i],
spectra[0][i]
);
}
}
}
#[test]
fn scratch_budget_does_not_change_results() {
use ndarray::Array2;
use single_svdlib::matrix::kernels::{scatter_mul, DEFAULT_SCRATCH_BUDGET};
let a = dense_like(500, 120, |i, j| (((i * 11 + j * 5) % 17) as f64) - 8.0);
let rhs = Array2::from_shape_fn((500, 24), |(i, j)| ((i * 3 + j) % 9) as f64 - 4.0);
let mut reference = Array2::zeros((120, 24));
scatter_mul(a.view(), rhs.view(), reference.view_mut(), usize::MAX);
for budget in [0usize, 1, 1024, DEFAULT_SCRATCH_BUDGET] {
let mut got = Array2::zeros((120, 24));
scatter_mul(a.view(), rhs.view(), got.view_mut(), budget);
for (x, y) in got.iter().zip(reference.iter()) {
approx::assert_relative_eq!(x, y, max_relative = 1e-12, epsilon = 1e-12);
}
}
}