use lapl::{
adjacency_to_laplacian, normalized_laplacian, random_walk_laplacian, spectral_embedding,
SpectralEmbeddingConfig,
};
use ndarray::{Array1, Array2};
use serde::Deserialize;
const FIXTURE: &str = include_str!("fixtures/rosetta/lapl_spectral.json");
#[derive(Deserialize)]
struct Fixture {
k: usize,
adj: Vec<Vec<f64>>,
expected: Expected,
}
#[derive(Deserialize)]
struct Expected {
lap_unnormalized: Vec<Vec<f64>>,
lap_normalized: Vec<Vec<f64>>,
lap_random_walk: Vec<Vec<f64>>,
eig_subspace: Vec<Vec<f64>>,
}
fn to_array2(rows: &[Vec<f64>]) -> Array2<f64> {
let d = rows[0].len();
let mut a = Array2::zeros((rows.len(), d));
for (i, r) in rows.iter().enumerate() {
for (j, &v) in r.iter().enumerate() {
a[[i, j]] = v;
}
}
a
}
fn mat_close(got: &Array2<f64>, want: &[Vec<f64>], label: &str) {
assert_eq!(got.nrows(), want.len(), "{label}: row count");
for (i, row) in want.iter().enumerate() {
for (j, &w) in row.iter().enumerate() {
let tol = 1e-9 * (1.0 + w.abs());
let diff = (got[[i, j]] - w).abs();
assert!(
diff <= tol,
"{label}[{i}][{j}]: lapl={} scipy={w} diff={diff}",
got[[i, j]]
);
}
}
}
#[test]
fn rosetta_laplacians_match_scipy() {
let fx: Fixture = serde_json::from_str(FIXTURE).expect("parse rosetta fixture");
let a = to_array2(&fx.adj);
mat_close(
&adjacency_to_laplacian(&a),
&fx.expected.lap_unnormalized,
"lap_unnormalized",
);
mat_close(
&normalized_laplacian(&a),
&fx.expected.lap_normalized,
"lap_normalized",
);
mat_close(
&random_walk_laplacian(&a),
&fx.expected.lap_random_walk,
"lap_random_walk",
);
}
#[test]
fn rosetta_spectral_embedding_spans_reference_subspace() {
let fx: Fixture = serde_json::from_str(FIXTURE).expect("parse rosetta fixture");
let a = to_array2(&fx.adj);
let cfg = SpectralEmbeddingConfig {
skip_first: false,
row_normalize: false,
..Default::default()
};
let u = spectral_embedding(&a, fx.k, &cfg).expect("spectral embedding");
assert_eq!(u.ncols(), fx.k);
let gram = u.t().dot(&u);
for i in 0..fx.k {
for j in 0..fx.k {
let want = if i == j { 1.0 } else { 0.0 };
assert!(
(gram[[i, j]] - want).abs() < 1e-7,
"U^T U not identity at [{i}][{j}]: {}",
gram[[i, j]]
);
}
}
for (c, col) in fx.expected.eig_subspace.iter().enumerate() {
let u_ref = Array1::from(col.clone());
let coeffs = u.t().dot(&u_ref); let proj = u.dot(&coeffs); let residual: f64 = (&u_ref - &proj).mapv(|x| x * x).sum().sqrt();
assert!(
residual < 1e-6,
"reference eigenvector {c} not in span(U): residual {residual}"
);
}
}