ruvector/crates/ruvector-sparsifier/tests/integration_tests.rs
ruvnet 96d8fdc172 chore(workspace): cargo fmt — mechanical whitespace fix across 427 files
Pre-existing rustfmt drift across the workspace was blocking CI's
`Rustfmt` check on PR #373 + PR #377. Running plain `cargo fmt`
reformats 427 files; no semantic changes, no logic changes, no
behavior changes — just what rustfmt already wanted.

None of the touched files are in ruvector-rabitq, ruvector-rulake,
or the new mirror-rulake workflow — those were already fmt-clean
per the per-crate checks on commits 5a4b0d782, 5f32fd450, f5003bc7b.
Drift is in cognitum-gate-kernel, mcp-brain, nervous-system,
prime-radiant, ruqu-core, ruvector-attention, ruvector-mincut,
ruvix/* and sub-crates, plus several examples.

Verified post-fmt:
  cargo check -p ruvector-rabitq -p ruvector-rulake            → clean
  cargo clippy -p ... -p ... --all-targets -- -D warnings      → clean
  cargo test   -p ... -p ... --release                         → 82/82 pass

Intentionally does NOT touch clippy drift — many more warnings
(missing docs, precision-loss casts, too-many-args, unsafe-safety-
docs) spread across unrelated crates, each category a cross-cutting
design decision that deserves its own review.

With this commit Rustfmt CI goes green on PR #373 and PR #377.
Clippy will still fail — that's honest pre-existing state for a
separate dedicated PR.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-24 10:44:02 -04:00

332 lines
9.9 KiB
Rust

//! Integration and property tests for ruvector-sparsifier.
use ruvector_sparsifier::{
AdaptiveGeoSpar, SparseGraph, Sparsifier, SparsifierConfig, SpectralAuditor,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn triangle() -> SparseGraph {
SparseGraph::from_edges(&[(0, 1, 1.0), (1, 2, 1.0), (2, 0, 1.0)])
}
fn path(n: usize) -> SparseGraph {
let edges: Vec<_> = (0..n - 1).map(|i| (i, i + 1, 1.0)).collect();
SparseGraph::from_edges(&edges)
}
fn complete(n: usize) -> SparseGraph {
let mut edges = Vec::new();
for i in 0..n {
for j in (i + 1)..n {
edges.push((i, j, 1.0));
}
}
SparseGraph::from_edges(&edges)
}
fn grid(rows: usize, cols: usize) -> SparseGraph {
let mut edges = Vec::new();
let idx = |r: usize, c: usize| r * cols + c;
for r in 0..rows {
for c in 0..cols {
if c + 1 < cols {
edges.push((idx(r, c), idx(r, c + 1), 1.0));
}
if r + 1 < rows {
edges.push((idx(r, c), idx(r + 1, c), 1.0));
}
}
}
SparseGraph::from_edges(&edges)
}
fn knn_random(n: usize, k: usize, seed: u64) -> SparseGraph {
use rand::prelude::*;
let mut rng = StdRng::seed_from_u64(seed);
let mut edges = Vec::new();
for u in 0..n {
for _ in 0..k {
let v = rng.gen_range(0..n);
if u != v {
let w = rng.gen::<f64>() + 0.1;
edges.push((u, v, w));
}
}
}
SparseGraph::from_edges(&edges)
}
// ---------------------------------------------------------------------------
// Spectral quality tests
// ---------------------------------------------------------------------------
#[test]
fn test_sparsifier_preserves_laplacian_on_triangle() {
let g = triangle();
let config = SparsifierConfig {
epsilon: 0.5,
..Default::default()
};
let spar = AdaptiveGeoSpar::build(&g, config).unwrap();
// For a 3-vertex graph, the sparsifier should be near-exact.
let x = vec![1.0, 0.0, -1.0];
let full_val = g.laplacian_quadratic_form(&x);
let spec_val = spar.sparsifier().laplacian_quadratic_form(&x);
let rel_err = (full_val - spec_val).abs() / full_val.abs().max(1e-15);
assert!(
rel_err < 0.6,
"Relative error {} exceeds 0.6 on triangle",
rel_err
);
}
#[test]
fn test_sparsifier_preserves_laplacian_on_grid() {
let g = grid(5, 5);
let config = SparsifierConfig {
epsilon: 0.3,
edge_budget_factor: 12,
..Default::default()
};
let spar = AdaptiveGeoSpar::build(&g, config).unwrap();
let auditor = SpectralAuditor::new(50, 0.5);
let result = auditor.audit_quadratic_form(spar.full_graph(), spar.sparsifier(), 50);
// Grid with generous epsilon should pass.
assert!(
result.avg_error < 1.0,
"Average error {} too high on 5x5 grid",
result.avg_error
);
}
#[test]
fn test_sparsifier_reduces_edges_on_complete_graph() {
let g = complete(20);
let config = SparsifierConfig {
epsilon: 0.3,
edge_budget_factor: 8,
..Default::default()
};
let spar = AdaptiveGeoSpar::build(&g, config).unwrap();
// Complete(20) has 190 edges. Sparsifier should have fewer.
let full_edges = spar.full_graph().num_edges();
let spec_edges = spar.sparsifier().num_edges();
assert!(full_edges == 190);
assert!(
spec_edges < full_edges,
"Sparsifier ({}) should have fewer edges than full graph ({})",
spec_edges,
full_edges
);
assert!(spar.compression_ratio() > 1.0);
}
#[test]
fn test_sparsifier_on_knn_graph() {
let g = knn_random(200, 8, 42);
let config = SparsifierConfig {
epsilon: 0.3,
edge_budget_factor: 8,
..Default::default()
};
let spar = AdaptiveGeoSpar::build(&g, config).unwrap();
assert!(spar.sparsifier().num_edges() > 0);
assert!(spar.stats().vertex_count == 200);
// Run audit.
let result = spar.audit();
// With ε=0.3 and random walks, this may not always pass perfectly,
// but avg error should be reasonable.
assert!(result.n_probes > 0);
}
// ---------------------------------------------------------------------------
// Dynamic update tests
// ---------------------------------------------------------------------------
#[test]
fn test_sequential_inserts() {
let g = path(5);
let config = SparsifierConfig::default();
let mut spar = AdaptiveGeoSpar::build(&g, config).unwrap();
// Add cross edges to make it denser.
spar.insert_edge(0, 3, 1.0).unwrap();
spar.insert_edge(1, 4, 1.0).unwrap();
spar.insert_edge(0, 4, 1.0).unwrap();
assert_eq!(spar.full_graph().num_edges(), 7); // 4 path + 3 cross
assert_eq!(spar.stats().insertions, 3);
}
#[test]
fn test_sequential_deletes() {
let g = complete(5);
let config = SparsifierConfig::default();
let mut spar = AdaptiveGeoSpar::build(&g, config).unwrap();
spar.delete_edge(0, 1).unwrap();
spar.delete_edge(2, 3).unwrap();
assert_eq!(spar.full_graph().num_edges(), 8); // 10 - 2
assert_eq!(spar.stats().deletions, 2);
}
#[test]
fn test_insert_then_delete_roundtrip() {
let g = triangle();
let config = SparsifierConfig::default();
let mut spar = AdaptiveGeoSpar::build(&g, config).unwrap();
spar.insert_edge(0, 3, 2.0).unwrap();
assert!(spar.full_graph().has_edge(0, 3));
spar.delete_edge(0, 3).unwrap();
assert!(!spar.full_graph().has_edge(0, 3));
assert_eq!(spar.full_graph().num_edges(), 3);
}
#[test]
fn test_embedding_update() {
let g = knn_random(50, 5, 99);
let config = SparsifierConfig::default();
let mut spar = AdaptiveGeoSpar::build(&g, config).unwrap();
let old_neighbors: Vec<(usize, f64)> = spar.full_graph().neighbors(0).take(2).collect();
let new_neighbors = vec![(40, 1.5), (41, 2.0)];
spar.update_embedding(0, &old_neighbors, &new_neighbors)
.unwrap();
for &(v, _) in &old_neighbors {
assert!(!spar.full_graph().has_edge(0, v));
}
for &(v, _) in &new_neighbors {
assert!(spar.full_graph().has_edge(0, v));
}
}
// ---------------------------------------------------------------------------
// Audit tests
// ---------------------------------------------------------------------------
#[test]
fn test_audit_identical_graphs_always_pass() {
let g = grid(4, 4);
let auditor = SpectralAuditor::new(50, 0.001);
let result = auditor.audit_quadratic_form(&g, &g, 50);
assert!(result.passed);
assert!(result.max_error < 1e-10);
}
#[test]
fn test_audit_cuts_identical() {
let g = complete(10);
let auditor = SpectralAuditor::new(20, 0.001);
let result = auditor.audit_cuts(&g, &g, 20);
assert!(result.passed);
}
#[test]
fn test_audit_detects_large_distortion() {
let g = complete(10);
// A very sparse subgraph should fail the audit.
let sparse = path(10);
let auditor = SpectralAuditor::new(50, 0.1);
let result = auditor.audit_quadratic_form(&g, &sparse, 50);
// Complete graph vs path should have significant distortion.
assert!(result.max_error > 0.1);
}
// ---------------------------------------------------------------------------
// Rebuild tests
// ---------------------------------------------------------------------------
#[test]
fn test_local_rebuild() {
let g = knn_random(100, 6, 77);
let config = SparsifierConfig::default();
let mut spar = AdaptiveGeoSpar::build(&g, config).unwrap();
spar.rebuild_local(&[0, 1, 2, 3, 4]).unwrap();
assert_eq!(spar.stats().local_rebuilds, 1);
assert!(spar.sparsifier().num_edges() > 0);
}
#[test]
fn test_full_rebuild_idempotent() {
let g = knn_random(50, 5, 55);
let config = SparsifierConfig::default();
let mut spar = AdaptiveGeoSpar::build(&g, config).unwrap();
let e1 = spar.sparsifier().num_edges();
spar.rebuild_full().unwrap();
let e2 = spar.sparsifier().num_edges();
// Edge counts may differ due to random sampling, but both should be > 0.
assert!(e1 > 0);
assert!(e2 > 0);
}
// ---------------------------------------------------------------------------
// Graph operations tests
// ---------------------------------------------------------------------------
#[test]
fn test_csr_roundtrip_preserves_structure() {
let g = grid(3, 3);
let (rp, ci, vals, n) = g.to_csr();
let g2 = SparseGraph::from_csr(&rp, &ci, &vals, n);
assert_eq!(g.num_vertices(), g2.num_vertices());
assert_eq!(g.num_edges(), g2.num_edges());
// Check Laplacian form is preserved.
let x: Vec<f64> = (0..9).map(|i| i as f64).collect();
let v1 = g.laplacian_quadratic_form(&x);
let v2 = g2.laplacian_quadratic_form(&x);
assert!((v1 - v2).abs() < 1e-10);
}
#[test]
fn test_weighted_degree() {
let g = SparseGraph::from_edges(&[(0, 1, 2.0), (0, 2, 3.0), (0, 3, 5.0)]);
assert!((g.weighted_degree(0) - 10.0).abs() < 1e-10);
}
// ---------------------------------------------------------------------------
// Stats and config tests
// ---------------------------------------------------------------------------
#[test]
fn test_config_serialization() {
let config = SparsifierConfig::default();
let json = serde_json::to_string(&config).unwrap();
let config2: SparsifierConfig = serde_json::from_str(&json).unwrap();
assert!((config.epsilon - config2.epsilon).abs() < 1e-10);
assert_eq!(config.edge_budget_factor, config2.edge_budget_factor);
}
#[test]
fn test_stats_compression_ratio() {
let g = complete(15);
let config = SparsifierConfig {
edge_budget_factor: 6,
..Default::default()
};
let spar = AdaptiveGeoSpar::build(&g, config).unwrap();
let stats = spar.stats();
assert_eq!(stats.full_edge_count, 105); // C(15,2)
assert!(stats.compression_ratio > 1.0);
assert_eq!(stats.full_rebuilds, 1);
}