use crate::graph::GraphParams;
use crate::tests::test_helpers;
use approx::abs_diff_eq;
use smartcore::linalg::basic::{arrays::Array2, matrix::DenseMatrix};
use sprs::CsMat;
use crate::laplacian::*;
use log::debug;
fn create_test_vectors() -> Vec<Vec<f64>> {
vec![
vec![1.0, 0.0, 0.0], vec![0.8, 0.6, 0.0], vec![0.0, 1.0, 0.0], vec![0.0, 0.8, 0.6], vec![0.0, 0.0, 1.0], ]
}
fn default_params() -> GraphParams {
GraphParams {
eps: 0.5,
k: 3,
topk: 2,
p: 2.0,
sigma: Some(0.1),
normalise: false,
sparsity_check: true,
}
}
#[test]
fn test_basic_laplacian_construction() {
let items = create_test_vectors();
let params = default_params();
let laplacian = build_laplacian_matrix(
DenseMatrix::<f64>::from_2d_vec(&items).unwrap().transpose(),
¶ms,
None,
false,
);
assert_eq!(laplacian.nnodes, 5);
assert_eq!(laplacian.matrix.shape(), (3, 3));
assert_eq!(laplacian.graph_params, params);
}
#[test]
fn test_laplacian_mathematical_properties() {
let items = create_test_vectors();
let params = default_params();
let laplacian = build_laplacian_matrix(
DenseMatrix::<f64>::from_2d_vec(&items).unwrap().transpose(),
¶ms,
None,
false,
);
let n = laplacian.nnodes;
for (i, row) in laplacian.matrix.outer_iterator().enumerate() {
let row_sum: f64 = row.iter().map(|(_, &value)| value).sum();
assert!(
abs_diff_eq!(row_sum, 0.0, epsilon = 1e-12),
r#"Row {i} sum should be zero, got {row_sum:.2e}"#
);
}
let mut asymmetry_violations = 0;
for (i, row) in laplacian.matrix.outer_iterator().enumerate() {
for (j, &l_ij) in row.iter() {
let l_ji = laplacian.matrix.get(j, i).unwrap_or(&0.0);
let diff = (l_ij - l_ji).abs();
if diff > 1e-12 {
asymmetry_violations += 1;
if asymmetry_violations <= 5 {
debug!(
"Asymmetry at ({}, {}): L_ij={:.2e}, L_ji={:.2e}, diff={:.2e}",
i, j, l_ij, l_ji, diff
);
}
}
assert!(
abs_diff_eq!(l_ij, l_ji, epsilon = 1e-12),
r#"Matrix should be symmetric: L[{i},{j}]={l_ij:.2e} != L[{j},{i}]={l_ji:.2e}"#
);
}
}
for i in 0..n {
let diagonal = laplacian.matrix.get(i, i).copied().unwrap_or(0.0);
assert!(
diagonal >= -1e-12,
"Diagonal L[{},{}] should be non-negative, got {:.6}",
i,
i,
diagonal
);
}
for (i, row) in laplacian.matrix.outer_iterator().enumerate() {
for (j, &value) in row.iter() {
if i != j {
assert!(
value <= 1e-12,
"Off-diagonal L[{},{}] should be non-positive, got {:.6}",
i,
j,
value
);
}
}
}
let expected_max_nnz = n * (params.k + 1); assert!(
laplacian.matrix.nnz() <= expected_max_nnz,
"Matrix should be sparse: {} non-zeros > expected max {}",
laplacian.matrix.nnz(),
expected_max_nnz
);
assert_eq!(
laplacian.matrix.indices().len(),
laplacian.matrix.nnz(),
"CSR indices length should match nnz"
);
assert_eq!(
laplacian.matrix.data().len(),
laplacian.matrix.nnz(),
"CSR data length should match nnz"
);
debug!(
"✓ Laplacian mathematical properties verified for {}×{} sparse matrix with {} non-zeros",
n,
n,
laplacian.matrix.nnz()
);
}
#[test]
fn test_cosine_similarity_based_construction() {
let items = vec![
vec![1.0, 0.0], vec![0.707, 0.707], vec![0.0, 1.0], vec![-1.0, 0.0], ];
let params = GraphParams {
eps: 2.0, k: 3,
topk: 2,
p: 1.0,
sigma: Some(0.5), normalise: true,
sparsity_check: true,
};
let (_, adjacency) = test_helpers::build_laplacian_matrix_with_adjacency(&items, ¶ms);
let adj_01 = *adjacency.get(0, 1).unwrap(); let adj_02 = *adjacency.get(0, 2).unwrap(); let adj_03 = *adjacency.get(0, 3).unwrap();
assert!(
adj_01 > adj_02,
"More similar vectors should have higher weights: {} > {}",
adj_01,
adj_02
);
if adj_02 != adj_03 {
assert!(
adj_02 > adj_03,
"More similar vectors should have higher weights: {} > {}",
adj_02,
adj_03
);
} else {
debug!(
"Note: 90° and 180° vectors have equal weights ({}) - likely thresholded",
adj_02
);
}
assert!(
adj_01 > adj_02 + 0.05,
"45° similarity should be significantly higher than 90°: {} > {}",
adj_01,
adj_02 + 0.05
);
}
#[test]
fn test_eps_parameter_constraint() {
let items = vec![
vec![1.0, 0.0],
vec![0.5, 0.0], vec![0.0, 1.0], ];
let restrictive_params = GraphParams {
eps: 0.3, k: 10,
topk: 4,
p: 1.0,
sigma: Some(0.1),
normalise: true,
sparsity_check: true,
};
let (_, adjacency_restrictive) =
test_helpers::build_laplacian_matrix_with_adjacency(&items, &restrictive_params);
let permissive_params = GraphParams {
eps: 1.5, k: 10,
topk: 4,
p: 1.0,
sigma: Some(0.1),
normalise: true,
sparsity_check: true,
};
let (_, adjacency_permissive) =
test_helpers::build_laplacian_matrix_with_adjacency(&items, &permissive_params);
let count_restrictive = count_nonzero_adjacency(&adjacency_restrictive);
let count_permissive = count_nonzero_adjacency(&adjacency_permissive);
assert!(
count_permissive >= count_restrictive,
"Larger eps should allow more connections: {} >= {}",
count_permissive,
count_restrictive
);
}
#[test]
fn test_k_parameter_constraint() {
let items = create_test_vectors();
let small_k_params = GraphParams {
eps: 1.0,
k: 1, topk: 1,
p: 1.0,
sigma: Some(0.1),
normalise: true,
sparsity_check: true,
};
let large_k_params = GraphParams {
eps: 1.0,
k: 4, topk: 3,
p: 1.0,
sigma: Some(0.1),
normalise: true,
sparsity_check: true,
};
let (_, adj_small_k) =
test_helpers::build_laplacian_matrix_with_adjacency(&items, &small_k_params);
let (_, adj_large_k) =
test_helpers::build_laplacian_matrix_with_adjacency(&items, &large_k_params);
let connections_small_k = count_nonzero_adjacency(&adj_small_k);
let connections_large_k = count_nonzero_adjacency(&adj_large_k);
assert!(
connections_large_k >= connections_small_k,
"Larger k should allow more connections: {} >= {}",
connections_large_k,
connections_small_k
);
}
#[test]
#[should_panic(expected = "items should be at least of shape (2,2): (1,1)")]
fn test_insufficient_data_panics() {
let insufficient_items = vec![vec![1.0]]; let params = default_params();
build_laplacian_matrix(
DenseMatrix::<f64>::from_2d_vec(&insufficient_items).unwrap(),
¶ms,
None,
false,
);
}
#[test]
fn test_numerical_stability() {
let small_values = vec![vec![1e-10, 2e-10], vec![3e-10, 1e-10], vec![2e-10, 3e-10]];
let params = GraphParams {
eps: 1.0,
k: 2,
topk: 1,
p: 2.0,
sigma: Some(1e-8),
normalise: false,
sparsity_check: true,
};
let laplacian = build_laplacian_matrix(
DenseMatrix::<f64>::from_2d_vec(&small_values).unwrap(),
¶ms,
None,
false,
);
let mut _total_entries_checked = 0;
let mut finite_entries = 0;
for (i, row) in laplacian.matrix.outer_iterator().enumerate() {
for (j, &val) in row.iter() {
_total_entries_checked += 1;
assert!(
val.is_finite(),
"Matrix entry [{},{}] should be finite, got {}",
i,
j,
val
);
finite_entries += 1;
}
}
for i in 0..3 {
let diagonal_val = laplacian.matrix.get(i, i).copied().unwrap_or(0.0);
assert!(
diagonal_val.is_finite(),
"Diagonal entry [{},{}] should be finite, got {}",
i,
i,
diagonal_val
);
}
let has_nan = laplacian.matrix.data().iter().any(|&x| x.is_nan());
let has_inf = laplacian.matrix.data().iter().any(|&x| x.is_infinite());
assert!(!has_nan, "Matrix should not contain NaN values");
assert!(!has_inf, "Matrix should not contain infinite values");
assert!(
laplacian.matrix.nnz() >= 3, "Matrix should have at least diagonal entries, got {} non-zeros",
laplacian.matrix.nnz()
);
let max_possible_entries = 3 * 3; assert!(
laplacian.matrix.nnz() <= max_possible_entries,
"Matrix has more entries ({}) than possible ({})",
laplacian.matrix.nnz(),
max_possible_entries
);
assert_eq!(
laplacian.matrix.indices().len(),
laplacian.matrix.data().len(),
"CSR indices and data arrays should have same length"
);
let indptr = laplacian.matrix.indptr();
let indptr_slice = indptr.raw_storage();
for i in 1..indptr_slice.len() {
assert!(
indptr_slice[i] >= indptr_slice[i - 1],
"CSR row pointers should be monotonic: indptr[{}]={} < indptr[{}]={}",
i,
indptr_slice[i],
i - 1,
indptr_slice[i - 1]
);
}
debug!(
"✓ Numerical stability verified: checked {} stored entries, all finite",
finite_entries
);
debug!(
" Matrix sparsity: {}/{} entries stored ({:.1}%)",
laplacian.matrix.nnz(),
9, laplacian.matrix.nnz() as f64 / 9.0 * 100.0
);
}
fn count_nonzero_adjacency(adjacency: &CsMat<f64>) -> usize {
let (rows, cols) = adjacency.shape();
let mut count = 0;
for i in 0..rows {
for j in 0..cols {
let a = adjacency.get(i, j).unwrap_or(&0.0);
if i != j && a.abs() > 1e-12 {
count += 1;
}
}
}
count
}
#[test]
fn test_arrowspace_integration_pattern_sparse() {
let protein_like_data = vec![
vec![0.82, 0.11, 0.43, 0.28, 0.64],
vec![0.79, 0.12, 0.45, 0.29, 0.61],
vec![0.78, 0.13, 0.46, 0.27, 0.62],
vec![0.81, 0.10, 0.44, 0.26, 0.63],
];
let arrowspace_params = GraphParams {
eps: 1e-2,
k: 6,
topk: 3,
p: 2.0,
sigma: None,
normalise: true,
sparsity_check: true,
};
let laplacian = build_laplacian_matrix(
DenseMatrix::<f64>::from_2d_vec(&protein_like_data)
.unwrap()
.transpose(),
&arrowspace_params,
None,
false,
);
assert_eq!(laplacian.nnodes, 4);
let (rows, cols) = laplacian.matrix.shape();
assert_eq!((rows, cols), (5, 5));
let tol = 1e-10;
for i in 0..rows {
let mut row_sum = 0.0;
let mut off_diag_sum = 0.0;
let mut diag = 0.0;
if let Some(row) = laplacian.matrix.outer_view(i) {
for (j, &v) in row.iter() {
row_sum += v;
if j == i {
diag = v;
} else {
off_diag_sum += v;
let v_t = laplacian.matrix.get(j, i).copied().unwrap_or(0.0);
assert!(
(v - v_t).abs() < tol,
"Laplacian not symmetric at ({},{}) vs ({},{})",
i,
j,
j,
i
);
}
}
}
assert!(
row_sum.abs() < tol,
"Row {} does not sum to ~0 (sum = {})",
i,
row_sum
);
assert!(
(diag + off_diag_sum).abs() < tol,
"Diagonal consistency failed at row {} (diag = {}, off-sum = {})",
i,
diag,
off_diag_sum
);
}
}
#[test]
fn test_optimized_sparse_matrix_laplacian() {
let items = vec![
vec![1.0, 0.0, 0.0],
vec![0.8, 0.2, 0.0],
vec![0.0, 1.0, 0.0],
vec![0.0, 0.8, 0.2],
];
let params = GraphParams {
eps: 0.8,
k: 2,
topk: 1,
p: 2.0,
sigma: Some(0.1),
normalise: false,
sparsity_check: true,
};
let laplacian = build_laplacian_matrix(
DenseMatrix::<f64>::from_2d_vec(&items).unwrap().transpose(),
¶ms,
None,
false,
);
assert_eq!(laplacian.nnodes, 4);
assert_eq!(laplacian.matrix.shape().0, 3);
assert_eq!(laplacian.matrix.shape().1, 3);
assert!(
laplacian.matrix.nnz() > 0,
"Matrix should have some non-zero entries"
);
assert!(
laplacian.matrix.nnz() <= 16, "Matrix cannot have more than 16 entries"
);
for (i, row) in laplacian.matrix.outer_iterator().enumerate() {
let row_sum: f64 = row.iter().map(|(_, &value)| value).sum();
assert!(
row_sum.abs() < 1e-10,
"Row {} sum should be ~0, got {:.2e}",
i,
row_sum
);
}
for (i, row) in laplacian.matrix.outer_iterator().enumerate() {
for (j, &l_ij) in row.iter() {
let l_ji = laplacian.matrix.get(j, i).copied().unwrap_or(0.0);
let diff = (l_ij - l_ji).abs();
assert!(
diff < 1e-10,
"Matrix should be symmetric at ({},{}): L_ij={:.2e}, L_ji={:.2e}",
i,
j,
l_ij,
l_ji
);
}
}
for i in 0..4 {
let diagonal = laplacian.matrix.get(i, i).copied().unwrap_or(0.0);
assert!(
diagonal >= -1e-10,
"Diagonal L[{},{}] should be non-negative, got {:.6}",
i,
i,
diagonal
);
}
for (i, row) in laplacian.matrix.outer_iterator().enumerate() {
for (j, &value) in row.iter() {
if i != j {
assert!(
value <= 1e-10,
"Off-diagonal L[{},{}] should be non-positive, got {:.6}",
i,
j,
value
);
}
}
}
assert_eq!(
laplacian.matrix.indices().len(),
laplacian.matrix.data().len(),
"CSR indices and data should have same length"
);
assert_eq!(
laplacian.matrix.indptr().len(),
4, "CSR indptr should have length n+1"
);
let expected_max_nnz = 4 * (params.k + 1); assert!(
laplacian.matrix.nnz() <= expected_max_nnz,
"Matrix should be reasonably sparse: {} > expected max {}",
laplacian.matrix.nnz(),
expected_max_nnz
);
debug!("✓ Sparse matrix Laplacian test passed");
debug!(
" Matrix: {}×{} with {} non-zeros ({:.1}% sparse)",
4,
4,
laplacian.matrix.nnz(),
(1.0 - laplacian.matrix.nnz() as f64 / 16.0) * 100.0
);
debug!("Optimized Sparse Matrix Laplacian test passed");
}
#[test]
fn test_with_adjacency_output() {
let items = vec![vec![1.0, 0.0], vec![0.9, 0.1], vec![0.0, 1.0]];
let params = GraphParams {
eps: 0.5,
k: 2,
topk: 1,
p: 1.0,
sigma: Some(0.2),
normalise: false,
sparsity_check: true,
};
let (laplacian, adjacency) =
test_helpers::build_laplacian_matrix_with_adjacency(&items, ¶ms);
for i in 0..3 {
let diag_val = adjacency.get(i, i).copied().unwrap_or(0.0);
assert_eq!(
diag_val, 0.0,
"Adjacency diagonal [{},{}] should be zero",
i, i
);
}
for i in 0..3 {
let degree: f64 = if let Some(adj_row) = adjacency.outer_view(i) {
adj_row.iter().map(|(_, &value)| value).sum()
} else {
0.0
};
let laplacian_diag = laplacian.matrix.get(i, i).copied().unwrap_or(0.0);
assert!(
(laplacian_diag - degree).abs() < 1e-10,
"Laplacian diagonal L[{},{}]={:.6} should equal degree {:.6}",
i,
i,
laplacian_diag,
degree
);
for j in 0..3 {
if i != j {
let adjacency_val = adjacency.get(i, j).copied().unwrap_or(0.0);
let laplacian_val = laplacian.matrix.get(i, j).copied().unwrap_or(0.0);
let expected = -adjacency_val;
assert!(
(laplacian_val - expected).abs() < 1e-10,
"Laplacian L[{},{}]={:.6} should equal -A[{},{}]={:.6}",
i,
j,
laplacian_val,
i,
j,
expected
);
}
}
}
for (i, lapl_row) in laplacian.matrix.outer_iterator().enumerate() {
for (j, &lapl_val) in lapl_row.iter() {
if i != j {
let adj_val = adjacency.get(i, j).copied().unwrap_or(0.0);
if adj_val != 0.0 {
assert!(
lapl_val.abs() > 1e-12,
"If A[{},{}]={:.6} != 0, then L[{},{}] should be non-zero",
i,
j,
adj_val,
i,
j
);
}
}
}
}
for (i, adj_row) in adjacency.outer_iterator().enumerate() {
for (j, &adj_val) in adj_row.iter() {
let adj_transpose = adjacency.get(j, i).copied().unwrap_or(0.0);
assert!(
(adj_val - adj_transpose).abs() < 1e-10,
"Adjacency should be symmetric: A[{},{}]={:.6} != A[{},{}]={:.6}",
i,
j,
adj_val,
j,
i,
adj_transpose
);
}
}
debug!(
"✓ Adjacency matrix: {}×{} with {} non-zeros",
adjacency.shape().0,
adjacency.shape().1,
adjacency.nnz()
);
debug!(
"✓ Laplacian matrix: {}×{} with {} non-zeros",
laplacian.matrix.shape().0,
laplacian.matrix.shape().1,
laplacian.matrix.nnz()
);
assert!(
laplacian.matrix.nnz() >= adjacency.nnz(),
"Laplacian ({} nnz) should have at least as many entries as adjacency ({} nnz)",
laplacian.matrix.nnz(),
adjacency.nnz()
);
debug!("Sparse Adjacency + Laplacian test passed");
}