//! Spectral Coherence Score for graph index health monitoring. //! //! Provides a composite metric measuring structural health of graph indices //! using spectral graph theory properties. Self-contained, no external solver deps. use serde::{Deserialize, Serialize}; /// Compressed Sparse Row matrix for Laplacian representation. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CsrMatrixView { pub row_ptr: Vec, pub col_indices: Vec, pub values: Vec, pub rows: usize, pub cols: usize, } impl CsrMatrixView { pub fn new( row_ptr: Vec, col_indices: Vec, values: Vec, rows: usize, cols: usize, ) -> Self { Self { row_ptr, col_indices, values, rows, cols, } } /// Build a symmetric adjacency CSR matrix from edges `(u, v, weight)`. pub fn from_edges(n: usize, edges: &[(usize, usize, f64)]) -> Self { let mut entries: Vec<(usize, usize, f64)> = Vec::with_capacity(edges.len() * 2); for &(u, v, w) in edges { entries.push((u, v, w)); if u != v { entries.push((v, u, w)); } } entries.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1))); Self::from_sorted_entries(n, &entries) } /// Sparse matrix-vector product: y = A * x. pub fn spmv(&self, x: &[f64]) -> Vec { let mut y = vec![0.0; self.rows]; for i in 0..self.rows { let (start, end) = (self.row_ptr[i], self.row_ptr[i + 1]); y[i] = (start..end) .map(|j| self.values[j] * x[self.col_indices[j]]) .sum(); } y } /// Build the graph Laplacian L = D - A from edges. pub fn build_laplacian(n: usize, edges: &[(usize, usize, f64)]) -> Self { let mut degree = vec![0.0_f64; n]; let mut entries: Vec<(usize, usize, f64)> = Vec::with_capacity(edges.len() * 2 + n); for &(u, v, w) in edges { degree[u] += w; if u != v { degree[v] += w; entries.push((u, v, -w)); entries.push((v, u, -w)); } } for i in 0..n { entries.push((i, i, degree[i])); } entries.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1))); Self::from_sorted_entries(n, &entries) } fn from_sorted_entries(n: usize, entries: &[(usize, usize, f64)]) -> Self { let mut row_ptr = vec![0usize; n + 1]; let mut col_indices = Vec::with_capacity(entries.len()); let mut values = Vec::with_capacity(entries.len()); for &(r, c, v) in entries { row_ptr[r + 1] += 1; col_indices.push(c); values.push(v); } for i in 0..n { row_ptr[i + 1] += row_ptr[i]; } Self { row_ptr, col_indices, values, rows: n, cols: n, } } } /// Configuration for spectral coherence computation. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpectralConfig { pub alpha: f64, // Fiedler weight (default 0.3) pub beta: f64, // Spectral gap weight (default 0.3) pub gamma: f64, // Effective resistance weight (default 0.2) pub delta: f64, // Degree regularity weight (default 0.2) pub max_iterations: usize, // Power iteration max (default 50) pub tolerance: f64, // Convergence tolerance (default 1e-6) pub refresh_threshold: usize, // Updates before full recompute (default 100) } impl Default for SpectralConfig { fn default() -> Self { Self { alpha: 0.3, beta: 0.3, gamma: 0.2, delta: 0.2, max_iterations: 50, tolerance: 1e-6, refresh_threshold: 100, } } } /// Composite spectral coherence score with individual components. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SpectralCoherenceScore { pub fiedler: f64, // Normalized Fiedler value [0,1] pub spectral_gap: f64, // Spectral gap ratio [0,1] pub effective_resistance: f64, // Effective resistance score [0,1] pub degree_regularity: f64, // Degree regularity score [0,1] pub composite: f64, // Weighted composite SCS [0,1] } // --- Internal helpers --- fn dot(a: &[f64], b: &[f64]) -> f64 { a.iter().zip(b).map(|(x, y)| x * y).sum() } fn norm(v: &[f64]) -> f64 { dot(v, v).sqrt() } /// CG solve for L*x = b with null-space deflation (L is graph Laplacian). fn cg_solve(lap: &CsrMatrixView, b: &[f64], max_iter: usize, tol: f64) -> Vec { let n = lap.rows; let inv_n = 1.0 / n as f64; let b_mean: f64 = b.iter().sum::() * inv_n; let b_def: Vec = b.iter().map(|v| v - b_mean).collect(); let mut x = vec![0.0; n]; let mut r = b_def.clone(); let mut p = r.clone(); let mut rs_old = dot(&r, &r); if rs_old < tol * tol { return x; } for _ in 0..max_iter { let mut ap = lap.spmv(&p); let ap_mean: f64 = ap.iter().sum::() * inv_n; ap.iter_mut().for_each(|v| *v -= ap_mean); let pap = dot(&p, &ap); if pap.abs() < 1e-30 { break; } let alpha = rs_old / pap; for i in 0..n { x[i] += alpha * p[i]; r[i] -= alpha * ap[i]; } let rs_new = dot(&r, &r); if rs_new.sqrt() < tol { break; } let beta = rs_new / rs_old; for i in 0..n { p[i] = r[i] + beta * p[i]; } rs_old = rs_new; } x } /// Deflate vector: remove component along all-ones, then normalize. fn deflate_and_normalize(v: &mut Vec) { let n = v.len(); let inv_sqrt_n = 1.0 / (n as f64).sqrt(); let proj: f64 = v.iter().sum::() * inv_sqrt_n; v.iter_mut().for_each(|x| *x -= proj * inv_sqrt_n); let n2 = norm(v); if n2 > 1e-30 { v.iter_mut().for_each(|x| *x /= n2); } } /// Estimate the Fiedler value (second smallest eigenvalue) and eigenvector /// using inverse iteration with null-space deflation. pub fn estimate_fiedler(lap: &CsrMatrixView, max_iter: usize, tol: f64) -> (f64, Vec) { let n = lap.rows; if n <= 1 { return (0.0, vec![0.0; n]); } // Initial vector orthogonal to all-ones. let mut v: Vec = (0..n).map(|i| i as f64 - (n as f64 - 1.0) / 2.0).collect(); deflate_and_normalize(&mut v); let mut eigenvalue = 0.0; // Use fewer outer iterations (convergence is typically fast for inverse iteration) let outer = max_iter.min(8); // Inner CG iterations: enough for approximate solve let inner = max_iter.min(15); for _ in 0..outer { let mut w = cg_solve(lap, &v, inner, tol * 0.1); deflate_and_normalize(&mut w); if norm(&w) < 1e-30 { break; } let lv = lap.spmv(&w); eigenvalue = dot(&w, &lv); let residual: f64 = lv .iter() .zip(w.iter()) .map(|(li, wi)| (li - eigenvalue * wi).powi(2)) .sum::() .sqrt(); v = w; if residual < tol { break; } } (eigenvalue.max(0.0), v) } /// Estimate the largest eigenvalue of the Laplacian via power iteration. pub fn estimate_largest_eigenvalue(lap: &CsrMatrixView, max_iter: usize) -> f64 { let n = lap.rows; if n == 0 { return 0.0; } let mut v = vec![1.0 / (n as f64).sqrt(); n]; let mut ev = 0.0; // Power iteration converges fast for the largest eigenvalue let iters = max_iter.min(10); for _ in 0..iters { let w = lap.spmv(&v); let wn = norm(&w); if wn < 1e-30 { return 0.0; } ev = dot(&v, &w); v.iter_mut() .zip(w.iter()) .for_each(|(vi, wi)| *vi = wi / wn); } ev.max(0.0) } /// Spectral gap ratio: fiedler / largest eigenvalue. pub fn estimate_spectral_gap(fiedler: f64, largest: f64) -> f64 { if largest < 1e-30 { 0.0 } else { (fiedler / largest).clamp(0.0, 1.0) } } /// Degree regularity: 1 - (std_dev / mean) of vertex degrees. 1.0 = perfectly regular. pub fn compute_degree_regularity(lap: &CsrMatrixView) -> f64 { let n = lap.rows; if n == 0 { return 1.0; } let degrees: Vec = (0..n) .map(|i| { let (s, e) = (lap.row_ptr[i], lap.row_ptr[i + 1]); (s..e) .find(|&j| lap.col_indices[j] == i) .map_or(0.0, |j| lap.values[j]) }) .collect(); let mean = degrees.iter().sum::() / n as f64; if mean < 1e-30 { return 1.0; } let std = (degrees.iter().map(|d| (d - mean).powi(2)).sum::() / n as f64).sqrt(); (1.0 - std / mean).clamp(0.0, 1.0) } /// Estimate average effective resistance by deterministic sampling of vertex pairs. pub fn estimate_effective_resistance_sampled(lap: &CsrMatrixView, n_samples: usize) -> f64 { let n = lap.rows; if n < 2 { return 0.0; } let total_pairs = n * (n - 1) / 2; let step = if total_pairs <= n_samples { 1 } else { total_pairs / n_samples }; let max_s = n_samples.min(total_pairs); // Fewer CG iterations for resistance estimation (approximate is fine) let cg_iters = 10; let (mut total, mut sampled, mut idx) = (0.0, 0usize, 0usize); 'outer: for u in 0..n { for v in (u + 1)..n { if idx % step == 0 { let mut rhs = vec![0.0; n]; rhs[u] = 1.0; rhs[v] = -1.0; let x = cg_solve(lap, &rhs, cg_iters, 1e-6); total += (x[u] - x[v]).abs(); sampled += 1; if sampled >= max_s { break 'outer; } } idx += 1; } } if sampled == 0 { 0.0 } else { total / sampled as f64 } } /// Tracks spectral coherence incrementally, recomputing fully when needed. pub struct SpectralTracker { config: SpectralConfig, fiedler_estimate: f64, gap_estimate: f64, resistance_estimate: f64, regularity: f64, updates_since_refresh: usize, fiedler_vector: Option>, } impl SpectralTracker { pub fn new(config: SpectralConfig) -> Self { Self { config, fiedler_estimate: 0.0, gap_estimate: 0.0, resistance_estimate: 0.0, regularity: 1.0, updates_since_refresh: 0, fiedler_vector: None, } } /// Full spectral computation from a Laplacian. pub fn compute(&mut self, lap: &CsrMatrixView) -> SpectralCoherenceScore { self.full_recompute(lap); self.build_score() } /// Incremental update using first-order perturbation: delta_lambda ~= v^T(delta_L)v. pub fn update_edge(&mut self, lap: &CsrMatrixView, u: usize, v: usize, weight_delta: f64) { self.updates_since_refresh += 1; if self.needs_refresh() || self.fiedler_vector.is_none() { self.full_recompute(lap); return; } if let Some(ref fv) = self.fiedler_vector { if u < fv.len() && v < fv.len() { let diff = fv[u] - fv[v]; self.fiedler_estimate = (self.fiedler_estimate + weight_delta * diff * diff).max(0.0); let largest = estimate_largest_eigenvalue(lap, self.config.max_iterations); self.gap_estimate = estimate_spectral_gap(self.fiedler_estimate, largest); } } self.regularity = compute_degree_regularity(lap); } pub fn score(&self) -> f64 { self.build_score().composite } pub fn full_recompute(&mut self, lap: &CsrMatrixView) { let (fiedler_raw, fv) = estimate_fiedler(lap, self.config.max_iterations, self.config.tolerance); let largest = estimate_largest_eigenvalue(lap, self.config.max_iterations); let n = lap.rows; self.fiedler_estimate = if n > 0 { (fiedler_raw / n as f64).clamp(0.0, 1.0) } else { 0.0 }; self.gap_estimate = estimate_spectral_gap(fiedler_raw, largest); let r_raw = estimate_effective_resistance_sampled(lap, 3.min(n * (n - 1) / 2)); self.resistance_estimate = 1.0 / (1.0 + r_raw); self.regularity = compute_degree_regularity(lap); self.fiedler_vector = Some(fv); self.updates_since_refresh = 0; } pub fn needs_refresh(&self) -> bool { self.updates_since_refresh >= self.config.refresh_threshold } fn build_score(&self) -> SpectralCoherenceScore { let c = self.config.alpha * self.fiedler_estimate + self.config.beta * self.gap_estimate + self.config.gamma * self.resistance_estimate + self.config.delta * self.regularity; SpectralCoherenceScore { fiedler: self.fiedler_estimate, spectral_gap: self.gap_estimate, effective_resistance: self.resistance_estimate, degree_regularity: self.regularity, composite: c.clamp(0.0, 1.0), } } } /// Alert types for graph index health degradation. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum HealthAlert { FragileIndex { fiedler: f64 }, PoorExpansion { gap: f64 }, HighResistance { resistance: f64 }, LowCoherence { scs: f64 }, RebuildRecommended { reason: String }, } /// Health monitor for HNSW graph indices using spectral coherence. pub struct HnswHealthMonitor { tracker: SpectralTracker, min_fiedler: f64, min_spectral_gap: f64, max_resistance: f64, min_composite_scs: f64, } impl HnswHealthMonitor { pub fn new(config: SpectralConfig) -> Self { Self { tracker: SpectralTracker::new(config), min_fiedler: 0.05, min_spectral_gap: 0.01, max_resistance: 0.95, min_composite_scs: 0.3, } } pub fn update(&mut self, lap: &CsrMatrixView, edge_change: Option<(usize, usize, f64)>) { match edge_change { Some((u, v, d)) => self.tracker.update_edge(lap, u, v, d), None => self.tracker.full_recompute(lap), } } pub fn check_health(&self) -> Vec { let s = self.tracker.build_score(); let mut alerts = Vec::new(); if s.fiedler < self.min_fiedler { alerts.push(HealthAlert::FragileIndex { fiedler: s.fiedler }); } if s.spectral_gap < self.min_spectral_gap { alerts.push(HealthAlert::PoorExpansion { gap: s.spectral_gap, }); } if s.effective_resistance > self.max_resistance { alerts.push(HealthAlert::HighResistance { resistance: s.effective_resistance, }); } if s.composite < self.min_composite_scs { alerts.push(HealthAlert::LowCoherence { scs: s.composite }); } if alerts.len() >= 2 { alerts.push(HealthAlert::RebuildRecommended { reason: format!( "{} health issues detected. Full rebuild recommended.", alerts.len() ), }); } alerts } pub fn score(&self) -> SpectralCoherenceScore { self.tracker.build_score() } } #[cfg(test)] mod tests { use super::*; fn triangle() -> Vec<(usize, usize, f64)> { vec![(0, 1, 1.0), (1, 2, 1.0), (0, 2, 1.0)] } fn path4() -> Vec<(usize, usize, f64)> { vec![(0, 1, 1.0), (1, 2, 1.0), (2, 3, 1.0)] } fn cycle4() -> Vec<(usize, usize, f64)> { vec![(0, 1, 1.0), (1, 2, 1.0), (2, 3, 1.0), (3, 0, 1.0)] } #[test] fn test_laplacian_construction() { let lap = CsrMatrixView::build_laplacian(3, &triangle()); assert_eq!(lap.rows, 3); for i in 0..3 { let (s, e) = (lap.row_ptr[i], lap.row_ptr[i + 1]); let row_sum: f64 = lap.values[s..e].iter().sum(); assert!(row_sum.abs() < 1e-10, "Row {} sum = {}", i, row_sum); let diag = (s..e) .find(|&j| lap.col_indices[j] == i) .map(|j| lap.values[j]) .unwrap(); assert!((diag - 2.0).abs() < 1e-10, "Diag[{}] = {}", i, diag); } } #[test] fn test_fiedler_value_triangle() { // K3 eigenvalues: 0, 3, 3. Fiedler = 3.0. let lap = CsrMatrixView::build_laplacian(3, &triangle()); let (f, _) = estimate_fiedler(&lap, 200, 1e-8); assert!( (f - 3.0).abs() < 0.15, "Triangle Fiedler = {} (expected ~3.0)", f ); } #[test] fn test_fiedler_value_path() { // P4 eigenvalues: 0, 2-sqrt(2), 2, 2+sqrt(2). Fiedler ~= 0.5858. let lap = CsrMatrixView::build_laplacian(4, &path4()); let (f, _) = estimate_fiedler(&lap, 200, 1e-8); let expected = 2.0 - std::f64::consts::SQRT_2; assert!( (f - expected).abs() < 0.15, "Path Fiedler = {} (expected ~{})", f, expected ); } #[test] fn test_degree_regularity_regular_graph() { let lap = CsrMatrixView::build_laplacian(4, &cycle4()); assert!((compute_degree_regularity(&lap) - 1.0).abs() < 1e-10); } #[test] fn test_scs_bounds() { let mut t = SpectralTracker::new(SpectralConfig::default()); let s = t.compute(&CsrMatrixView::build_laplacian(4, &cycle4())); assert!(s.composite >= 0.0 && s.composite <= 1.0); assert!(s.fiedler >= 0.0 && s.fiedler <= 1.0); assert!(s.spectral_gap >= 0.0 && s.spectral_gap <= 1.0); assert!(s.effective_resistance >= 0.0 && s.effective_resistance <= 1.0); assert!(s.degree_regularity >= 0.0 && s.degree_regularity <= 1.0); } #[test] fn test_scs_monotonicity() { let full = vec![ (0, 1, 1.0), (0, 2, 1.0), (0, 3, 1.0), (1, 2, 1.0), (1, 3, 1.0), (2, 3, 1.0), ]; let sparse = vec![(0, 1, 1.0), (1, 2, 1.0), (2, 3, 1.0)]; let mut tf = SpectralTracker::new(SpectralConfig::default()); let mut ts = SpectralTracker::new(SpectralConfig::default()); let sf = tf.compute(&CsrMatrixView::build_laplacian(4, &full)); let ss = ts.compute(&CsrMatrixView::build_laplacian(4, &sparse)); assert!( sf.composite >= ss.composite, "Full {} < sparse {}", sf.composite, ss.composite ); } #[test] fn test_tracker_incremental() { let edges = vec![ (0, 1, 1.0), (1, 2, 1.0), (2, 3, 1.0), (3, 0, 1.0), (0, 2, 1.0), (1, 3, 1.0), ]; let mut tracker = SpectralTracker::new(SpectralConfig::default()); let lap = CsrMatrixView::build_laplacian(4, &edges); tracker.compute(&lap); // Small perturbation for accurate first-order approximation. let delta = 0.05; let updated: Vec<_> = edges .iter() .map(|&(u, v, w)| { if u == 1 && v == 3 { (u, v, w + delta) } else { (u, v, w) } }) .collect(); let lap_u = CsrMatrixView::build_laplacian(4, &updated); tracker.update_edge(&lap_u, 1, 3, delta); let si = tracker.score(); let mut tf = SpectralTracker::new(SpectralConfig::default()); let sf = tf.compute(&lap_u).composite; let diff = (si - sf).abs(); assert!( diff < 0.5 * sf.max(0.01), "Incremental {} vs full {} (diff {})", si, sf, diff ); // Verify forced refresh matches full recompute closely. let mut tr = SpectralTracker::new(SpectralConfig { refresh_threshold: 1, ..Default::default() }); tr.compute(&lap); tr.updates_since_refresh = 1; tr.update_edge(&lap_u, 1, 3, delta); assert!( (tr.score() - sf).abs() < 0.05, "Refreshed {} vs full {}", tr.score(), sf ); } #[test] fn test_health_alerts() { let weak = vec![(0, 1, 0.01), (1, 2, 0.01)]; let mut m = HnswHealthMonitor::new(SpectralConfig::default()); m.update(&CsrMatrixView::build_laplacian(3, &weak), None); let alerts = m.check_health(); assert!( alerts.iter().any(|a| matches!( a, HealthAlert::FragileIndex { .. } | HealthAlert::LowCoherence { .. } )), "Weak graph should trigger alerts. Got: {:?}", alerts ); let mut ms = HnswHealthMonitor::new(SpectralConfig::default()); ms.update(&CsrMatrixView::build_laplacian(3, &triangle()), None); assert!(ms.check_health().len() <= alerts.len()); } }