mirror of
https://github.com/ruvnet/RuVector.git
synced 2026-05-27 17:23:34 +00:00
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 commits5a4b0d782,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>
332 lines
9.9 KiB
Rust
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);
|
|
}
|