Files
wifi-densepose/examples/prime-radiant/src/spectral/analyzer.rs
ruv d803bfe2b1 Squashed 'vendor/ruvector/' content from commit b64c2172
git-subtree-dir: vendor/ruvector
git-subtree-split: b64c21726f2bb37286d9ee36a7869fef60cc6900
2026-02-28 14:39:40 -05:00

694 lines
21 KiB
Rust

//! Core Spectral Analyzer
//!
//! Provides the main `SpectralAnalyzer` struct for computing spectral properties
//! of graphs, including eigenvalues, eigenvectors, and derived invariants.
use super::lanczos::{LanczosAlgorithm, PowerIteration};
use super::types::{Graph, SparseMatrix, SpectralGap, Vector, Bottleneck, MinCutPrediction, EPS, NodeId};
use serde::{Deserialize, Serialize};
/// Core spectral analyzer for graph analysis
#[derive(Debug, Clone)]
pub struct SpectralAnalyzer {
/// The graph being analyzed
pub graph: Graph,
/// Graph Laplacian matrix
pub laplacian: SparseMatrix,
/// Normalized Laplacian matrix
pub normalized_laplacian: SparseMatrix,
/// Computed eigenvalues (sorted ascending)
pub eigenvalues: Vec<f64>,
/// Corresponding eigenvectors
pub eigenvectors: Vec<Vector>,
/// Configuration
config: SpectralConfig,
}
/// Configuration for spectral analysis
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpectralConfig {
/// Number of eigenvalues to compute
pub num_eigenvalues: usize,
/// Use normalized Laplacian
pub use_normalized: bool,
/// Maximum iterations for eigenvalue computation
pub max_iter: usize,
/// Convergence tolerance
pub tol: f64,
}
impl Default for SpectralConfig {
fn default() -> Self {
Self {
num_eigenvalues: 10,
use_normalized: true,
max_iter: 1000,
tol: 1e-10,
}
}
}
impl SpectralConfig {
/// Create a builder for configuration
pub fn builder() -> SpectralConfigBuilder {
SpectralConfigBuilder::default()
}
}
/// Builder for SpectralConfig
#[derive(Default)]
pub struct SpectralConfigBuilder {
config: SpectralConfig,
}
impl SpectralConfigBuilder {
/// Set number of eigenvalues to compute
pub fn num_eigenvalues(mut self, n: usize) -> Self {
self.config.num_eigenvalues = n;
self
}
/// Use normalized Laplacian
pub fn normalized(mut self, use_norm: bool) -> Self {
self.config.use_normalized = use_norm;
self
}
/// Set maximum iterations
pub fn max_iter(mut self, n: usize) -> Self {
self.config.max_iter = n;
self
}
/// Set convergence tolerance
pub fn tolerance(mut self, tol: f64) -> Self {
self.config.tol = tol;
self
}
/// Build the configuration
pub fn build(self) -> SpectralConfig {
self.config
}
}
impl SpectralAnalyzer {
/// Create a new spectral analyzer for a graph
pub fn new(graph: Graph) -> Self {
Self::with_config(graph, SpectralConfig::default())
}
/// Create with custom configuration
pub fn with_config(graph: Graph, config: SpectralConfig) -> Self {
let laplacian = graph.laplacian();
let normalized_laplacian = graph.normalized_laplacian();
Self {
graph,
laplacian,
normalized_laplacian,
eigenvalues: Vec::new(),
eigenvectors: Vec::new(),
config,
}
}
/// Compute the Laplacian spectrum
pub fn compute_laplacian_spectrum(&mut self) -> &[f64] {
let matrix = if self.config.use_normalized {
&self.normalized_laplacian
} else {
&self.laplacian
};
let lanczos = LanczosAlgorithm::new(self.config.num_eigenvalues);
let (eigenvalues, eigenvectors) = lanczos.compute_smallest(matrix);
self.eigenvalues = eigenvalues;
self.eigenvectors = eigenvectors;
&self.eigenvalues
}
/// Get the algebraic connectivity (second smallest eigenvalue)
/// Also known as the Fiedler value
pub fn algebraic_connectivity(&self) -> f64 {
if self.eigenvalues.len() < 2 {
return 0.0;
}
// Skip the first eigenvalue (should be 0 for connected graphs)
// Find the first non-trivial eigenvalue
for &ev in &self.eigenvalues {
if ev > EPS {
return ev;
}
}
0.0
}
/// Get the Fiedler vector (eigenvector for second smallest eigenvalue)
pub fn fiedler_vector(&self) -> Option<&Vector> {
if self.eigenvectors.len() < 2 {
return None;
}
// Find index of first non-trivial eigenvalue
for (i, &ev) in self.eigenvalues.iter().enumerate() {
if ev > EPS {
return self.eigenvectors.get(i);
}
}
None
}
/// Compute the spectral gap
pub fn spectral_gap(&self) -> SpectralGap {
let lambda_1 = self.algebraic_connectivity();
let lambda_2 = if self.eigenvalues.len() >= 3 {
// Find third non-trivial eigenvalue
let mut count = 0;
for &ev in &self.eigenvalues {
if ev > EPS {
count += 1;
if count == 2 {
return SpectralGap::new(lambda_1, ev);
}
}
}
lambda_1 * 2.0 // Default if not enough eigenvalues
} else {
lambda_1 * 2.0
};
SpectralGap::new(lambda_1, lambda_2)
}
/// Predict minimum cut difficulty using spectral gap
pub fn predict_min_cut(&self) -> MinCutPrediction {
let fiedler_value = self.algebraic_connectivity();
let n = self.graph.n;
let total_weight = self.graph.total_weight();
// Cheeger inequality bounds on isoperimetric number
// h(G) >= lambda_2 / 2 (lower bound)
// h(G) <= sqrt(2 * lambda_2) (upper bound)
let lower_bound = fiedler_value / 2.0;
let upper_bound = (2.0 * fiedler_value).sqrt();
// Predicted cut based on isoperimetric number and graph volume
let predicted_cut = if total_weight > EPS {
// Cut value ~ h(G) * min_volume
// For balanced cut, min_volume ~ total_weight / 2
let avg_bound = (lower_bound + upper_bound) / 2.0;
avg_bound * total_weight / 2.0
} else {
0.0
};
// Compute confidence based on spectral gap clarity
let gap = self.spectral_gap();
let confidence = if gap.ratio > 2.0 {
0.9 // Clear separation
} else if gap.ratio > 1.5 {
0.7
} else if gap.ratio > 1.2 {
0.5
} else {
0.3 // Gap unclear, low confidence
};
// Suggest cut nodes from Fiedler vector
let cut_nodes = self.find_spectral_cut();
MinCutPrediction {
predicted_cut,
lower_bound: lower_bound * total_weight / 2.0,
upper_bound: upper_bound * total_weight / 2.0,
confidence,
cut_nodes,
}
}
/// Find the optimal cut using the Fiedler vector
fn find_spectral_cut(&self) -> Vec<NodeId> {
let fiedler = match self.fiedler_vector() {
Some(v) => v,
None => return Vec::new(),
};
// Simple threshold at zero
let positive_nodes: Vec<NodeId> = fiedler
.iter()
.enumerate()
.filter(|(_, &v)| v > 0.0)
.map(|(i, _)| i)
.collect();
let negative_nodes: Vec<NodeId> = fiedler
.iter()
.enumerate()
.filter(|(_, &v)| v <= 0.0)
.map(|(i, _)| i)
.collect();
// Return the smaller set (typically defines the cut boundary)
if positive_nodes.len() <= negative_nodes.len() {
positive_nodes
} else {
negative_nodes
}
}
/// Detect structural bottlenecks via Fiedler vector analysis
pub fn detect_bottlenecks(&self) -> Vec<Bottleneck> {
let fiedler = match self.fiedler_vector() {
Some(v) => v.clone(),
None => return Vec::new(),
};
let n = self.graph.n;
let mut bottlenecks = Vec::new();
// Sort nodes by Fiedler value
let mut sorted_indices: Vec<usize> = (0..n).collect();
sorted_indices.sort_by(|&a, &b| {
fiedler[a].partial_cmp(&fiedler[b]).unwrap()
});
// Find bottleneck at median split
let mid = n / 2;
let left_set: Vec<NodeId> = sorted_indices[..mid].to_vec();
let right_set: Vec<NodeId> = sorted_indices[mid..].to_vec();
// Find crossing edges
let left_set_hashset: std::collections::HashSet<NodeId> =
left_set.iter().cloned().collect();
let mut crossing_edges = Vec::new();
for &u in &left_set {
for &(v, _) in &self.graph.adj[u] {
if !left_set_hashset.contains(&v) {
crossing_edges.push((u.min(v), u.max(v)));
}
}
}
crossing_edges.sort();
crossing_edges.dedup();
// Compute bottleneck score (conductance)
let left_volume: f64 = left_set.iter().map(|&i| self.graph.degree(i)).sum();
let right_volume: f64 = right_set.iter().map(|&i| self.graph.degree(i)).sum();
let cut_weight: f64 = crossing_edges
.iter()
.map(|&(u, v)| {
self.graph.adj[u]
.iter()
.find(|(n, _)| *n == v)
.map(|(_, w)| *w)
.unwrap_or(0.0)
})
.sum();
let min_volume = left_volume.min(right_volume);
let score = if min_volume > EPS {
cut_weight / min_volume
} else {
f64::INFINITY
};
let volume_ratio = if (left_volume + right_volume) > EPS {
left_volume.min(right_volume) / (left_volume + right_volume)
} else {
0.5
};
// Find nodes at the bottleneck (near zero in Fiedler vector)
let threshold = self.compute_fiedler_threshold(&fiedler);
let bottleneck_nodes: Vec<NodeId> = fiedler
.iter()
.enumerate()
.filter(|(_, &v)| v.abs() < threshold)
.map(|(i, _)| i)
.collect();
bottlenecks.push(Bottleneck {
nodes: bottleneck_nodes,
crossing_edges,
score,
volume_ratio,
});
// Look for additional bottlenecks at different thresholds
self.find_additional_bottlenecks(&sorted_indices, &fiedler, &mut bottlenecks);
bottlenecks
}
/// Compute adaptive threshold for Fiedler vector
fn compute_fiedler_threshold(&self, fiedler: &[f64]) -> f64 {
let max_val = fiedler.iter().cloned().fold(0.0f64, f64::max);
let min_val = fiedler.iter().cloned().fold(0.0f64, f64::min);
let range = max_val - min_val;
if range > EPS {
range * 0.1 // 10% of range
} else {
0.01
}
}
/// Find additional bottlenecks at quartile splits
fn find_additional_bottlenecks(
&self,
sorted_indices: &[usize],
fiedler: &[f64],
bottlenecks: &mut Vec<Bottleneck>,
) {
let n = self.graph.n;
// Check at quartiles
for &split_point in &[n / 4, 3 * n / 4] {
if split_point == 0 || split_point >= n {
continue;
}
let left_set: Vec<NodeId> = sorted_indices[..split_point].to_vec();
let left_set_hashset: std::collections::HashSet<NodeId> =
left_set.iter().cloned().collect();
let mut crossing_edges = Vec::new();
for &u in &left_set {
for &(v, _) in &self.graph.adj[u] {
if !left_set_hashset.contains(&v) {
crossing_edges.push((u.min(v), u.max(v)));
}
}
}
crossing_edges.sort();
crossing_edges.dedup();
let left_volume: f64 = left_set.iter().map(|&i| self.graph.degree(i)).sum();
let right_volume: f64 = sorted_indices[split_point..]
.iter()
.map(|&i| self.graph.degree(i))
.sum();
let cut_weight: f64 = crossing_edges
.iter()
.map(|&(u, v)| {
self.graph.adj[u]
.iter()
.find(|(n, _)| *n == v)
.map(|(_, w)| *w)
.unwrap_or(0.0)
})
.sum();
let min_volume = left_volume.min(right_volume);
let score = if min_volume > EPS {
cut_weight / min_volume
} else {
continue;
};
let volume_ratio = if (left_volume + right_volume) > EPS {
left_volume.min(right_volume) / (left_volume + right_volume)
} else {
0.5
};
// Only add if it's a significantly different bottleneck
if score < 0.9 * bottlenecks[0].score {
let threshold_val = fiedler[sorted_indices[split_point]].abs() * 0.5;
let bottleneck_nodes: Vec<NodeId> = fiedler
.iter()
.enumerate()
.filter(|(_, &v)| (v - fiedler[sorted_indices[split_point]]).abs() < threshold_val)
.map(|(i, _)| i)
.collect();
bottlenecks.push(Bottleneck {
nodes: bottleneck_nodes,
crossing_edges,
score,
volume_ratio,
});
}
}
// Sort bottlenecks by score (ascending - lower is tighter)
bottlenecks.sort_by(|a, b| a.score.partial_cmp(&b.score).unwrap());
}
/// Get spectral embedding of nodes (coordinates from eigenvectors)
pub fn spectral_embedding(&self, dimensions: usize) -> Vec<Vector> {
let n = self.graph.n;
let dim = dimensions.min(self.eigenvectors.len());
let mut embedding = vec![vec![0.0; dim]; n];
// Skip the trivial eigenvector (constant)
let start_idx = if self.eigenvalues.first().map(|&v| v < EPS).unwrap_or(false) {
1
} else {
0
};
for d in 0..dim {
let ev_idx = start_idx + d;
if ev_idx < self.eigenvectors.len() {
for i in 0..n {
embedding[i][d] = self.eigenvectors[ev_idx][i];
}
}
}
embedding
}
/// Compute the effective resistance between two nodes
pub fn effective_resistance(&self, u: NodeId, v: NodeId) -> f64 {
if u == v || self.eigenvalues.is_empty() {
return 0.0;
}
let mut resistance = 0.0;
// R_uv = sum_i (1/lambda_i) * (phi_i(u) - phi_i(v))^2
// Skip the zero eigenvalue
for (i, (&lambda, eigvec)) in self.eigenvalues.iter()
.zip(self.eigenvectors.iter())
.enumerate()
{
if lambda > EPS {
let diff = eigvec[u] - eigvec[v];
resistance += diff * diff / lambda;
}
}
resistance
}
/// Compute total effective resistance (Kirchhoff index)
pub fn kirchhoff_index(&self) -> f64 {
let n = self.graph.n;
if self.eigenvalues.is_empty() {
return f64::INFINITY;
}
// K(G) = n * sum_i (1/lambda_i) for lambda_i > 0
let sum_reciprocal: f64 = self.eigenvalues
.iter()
.filter(|&&lambda| lambda > EPS)
.map(|&lambda| 1.0 / lambda)
.sum();
n as f64 * sum_reciprocal
}
/// Estimate the spectral radius (largest eigenvalue)
pub fn spectral_radius(&self) -> f64 {
let power = PowerIteration::default();
let (lambda, _) = power.largest_eigenvalue(&self.laplacian);
lambda
}
/// Check if graph is bipartite using spectral properties
pub fn is_bipartite(&self) -> bool {
// A graph is bipartite iff lambda_max = -lambda_min for the adjacency matrix
let adj = self.graph.adjacency_matrix();
let power = PowerIteration::default();
let (lambda_max, _) = power.largest_eigenvalue(&adj);
let (lambda_min, _) = power.smallest_eigenvalue(&adj, 0.0);
(lambda_max + lambda_min).abs() < 0.01
}
/// Get the number of connected components from eigenvalue spectrum
pub fn spectral_components(&self) -> usize {
// Count eigenvalues very close to zero
self.eigenvalues
.iter()
.filter(|&&ev| ev.abs() < 1e-6)
.count()
.max(1)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_path_graph(n: usize) -> Graph {
let edges: Vec<(usize, usize, f64)> = (0..n - 1)
.map(|i| (i, i + 1, 1.0))
.collect();
Graph::from_edges(n, &edges)
}
fn create_cycle_graph(n: usize) -> Graph {
let mut edges: Vec<(usize, usize, f64)> = (0..n - 1)
.map(|i| (i, i + 1, 1.0))
.collect();
edges.push((n - 1, 0, 1.0)); // Close the cycle
Graph::from_edges(n, &edges)
}
fn create_complete_graph(n: usize) -> Graph {
let mut edges = Vec::new();
for i in 0..n {
for j in i + 1..n {
edges.push((i, j, 1.0));
}
}
Graph::from_edges(n, &edges)
}
#[test]
fn test_analyzer_path_graph() {
let g = create_path_graph(5);
let mut analyzer = SpectralAnalyzer::new(g);
analyzer.compute_laplacian_spectrum();
// Path graph should have small algebraic connectivity
let lambda_2 = analyzer.algebraic_connectivity();
assert!(lambda_2 > 0.0);
assert!(lambda_2 < 1.0);
// Should have one component
assert_eq!(analyzer.spectral_components(), 1);
}
#[test]
fn test_analyzer_complete_graph() {
let g = create_complete_graph(5);
let mut analyzer = SpectralAnalyzer::new(g);
analyzer.compute_laplacian_spectrum();
// Complete graph has high algebraic connectivity
let lambda_2 = analyzer.algebraic_connectivity();
assert!(lambda_2 > 0.5);
}
#[test]
fn test_fiedler_vector() {
let g = create_path_graph(6);
let mut analyzer = SpectralAnalyzer::new(g);
analyzer.compute_laplacian_spectrum();
let fiedler = analyzer.fiedler_vector();
assert!(fiedler.is_some());
let v = fiedler.unwrap();
assert_eq!(v.len(), 6);
// Fiedler vector should be approximately monotonic for path graph
// (either increasing or decreasing)
}
#[test]
fn test_bottleneck_detection() {
// Create a barbell graph (two cliques connected by a single edge)
let mut g = Graph::new(8);
// First clique (0, 1, 2, 3)
for i in 0..4 {
for j in i + 1..4 {
g.add_edge(i, j, 1.0);
}
}
// Second clique (4, 5, 6, 7)
for i in 4..8 {
for j in i + 1..8 {
g.add_edge(i, j, 1.0);
}
}
// Bridge
g.add_edge(3, 4, 1.0);
let mut analyzer = SpectralAnalyzer::new(g);
analyzer.compute_laplacian_spectrum();
let bottlenecks = analyzer.detect_bottlenecks();
assert!(!bottlenecks.is_empty());
// The bottleneck should include the bridge edge
let bridge_found = bottlenecks.iter().any(|b| {
b.crossing_edges.contains(&(3, 4))
});
assert!(bridge_found);
}
#[test]
fn test_min_cut_prediction() {
let g = create_path_graph(10);
let mut analyzer = SpectralAnalyzer::new(g);
analyzer.compute_laplacian_spectrum();
let prediction = analyzer.predict_min_cut();
assert!(prediction.predicted_cut > 0.0);
assert!(prediction.lower_bound <= prediction.upper_bound);
assert!(prediction.confidence > 0.0);
}
#[test]
fn test_effective_resistance() {
let g = create_path_graph(5);
let mut analyzer = SpectralAnalyzer::new(g);
analyzer.compute_laplacian_spectrum();
// Effective resistance should increase with distance
let r_01 = analyzer.effective_resistance(0, 1);
let r_04 = analyzer.effective_resistance(0, 4);
assert!(r_01 < r_04);
}
#[test]
fn test_cycle_bipartite() {
// Even cycle is bipartite
let even_cycle = create_cycle_graph(6);
let mut analyzer_even = SpectralAnalyzer::new(even_cycle);
analyzer_even.compute_laplacian_spectrum();
// Odd cycle is not bipartite
let odd_cycle = create_cycle_graph(5);
let mut analyzer_odd = SpectralAnalyzer::new(odd_cycle);
analyzer_odd.compute_laplacian_spectrum();
// Even cycle should be bipartite
assert!(analyzer_even.is_bipartite());
// Odd cycle should not be bipartite
assert!(!analyzer_odd.is_bipartite());
}
}