//! Sheaf Laplacian //! //! The sheaf Laplacian L_F generalizes the graph Laplacian to sheaves. //! It is defined as L_F = delta^* delta where delta is the coboundary. //! //! The spectrum of L_F reveals global structure: //! - Zero eigenvalues correspond to cohomology classes //! - The multiplicity of 0 equals the Betti number //! - Small eigenvalues indicate near-obstructions use super::sheaf::{Sheaf, SheafSection}; use crate::substrate::NodeId; use crate::substrate::SheafGraph; use ndarray::{Array1, Array2}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// Configuration for Laplacian computation #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LaplacianConfig { /// Tolerance for zero eigenvalues pub zero_tolerance: f64, /// Maximum iterations for iterative eigensolvers pub max_iterations: usize, /// Number of eigenvalues to compute pub num_eigenvalues: usize, /// Whether to compute eigenvectors pub compute_eigenvectors: bool, } impl Default for LaplacianConfig { fn default() -> Self { Self { zero_tolerance: 1e-8, max_iterations: 1000, num_eigenvalues: 10, compute_eigenvectors: true, } } } /// Spectrum of the sheaf Laplacian #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LaplacianSpectrum { /// Eigenvalues in ascending order pub eigenvalues: Vec, /// Eigenvectors (optional) pub eigenvectors: Option>>, /// Number of zero eigenvalues (cohomology dimension) pub null_space_dim: usize, /// Spectral gap (smallest positive eigenvalue) pub spectral_gap: Option, } impl LaplacianSpectrum { /// Get the n-th Betti number from null space dimension pub fn betti_number(&self) -> usize { self.null_space_dim } /// Check if there's a spectral gap pub fn has_spectral_gap(&self) -> bool { self.spectral_gap.is_some() } /// Get harmonic representatives (eigenvectors with zero eigenvalue) pub fn harmonic_representatives(&self) -> Vec<&Array1> { if let Some(ref evecs) = self.eigenvectors { evecs.iter().take(self.null_space_dim).collect() } else { Vec::new() } } } /// A harmonic representative of a cohomology class #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HarmonicRepresentative { /// The harmonic cochain (Laplacian = 0) pub cochain: HashMap>, /// L2 norm pub norm: f64, /// Associated eigenvalue (should be near zero) pub eigenvalue: f64, } impl HarmonicRepresentative { /// Create from vertex values pub fn new(cochain: HashMap>, eigenvalue: f64) -> Self { let norm = cochain .values() .map(|v| v.iter().map(|x| x * x).sum::()) .sum::() .sqrt(); Self { cochain, norm, eigenvalue, } } /// Normalize to unit norm pub fn normalize(&mut self) { if self.norm > 1e-10 { let scale = 1.0 / self.norm; for v in self.cochain.values_mut() { *v = &*v * scale; } self.norm = 1.0; } } } /// The sheaf Laplacian L_F = delta^* delta pub struct SheafLaplacian { /// Configuration config: LaplacianConfig, /// Edge list (source, target, edge_weight) edges: Vec<(NodeId, NodeId, f64)>, /// Vertex list with stalk dimensions vertices: Vec<(NodeId, usize)>, /// Total dimension total_dim: usize, /// Vertex to index mapping vertex_to_idx: HashMap, /// Vertex to offset in global vector vertex_to_offset: HashMap, } impl SheafLaplacian { /// Create a sheaf Laplacian from a SheafGraph pub fn from_graph(graph: &SheafGraph, config: LaplacianConfig) -> Self { let mut edges = Vec::new(); let mut vertices = Vec::new(); let mut vertex_to_idx = HashMap::new(); let mut vertex_to_offset = HashMap::new(); // Collect vertices let mut offset = 0; for (idx, node_id) in graph.node_ids().into_iter().enumerate() { if let Some(node) = graph.get_node(node_id) { let dim = node.state.dim(); vertices.push((node_id, dim)); vertex_to_idx.insert(node_id, idx); vertex_to_offset.insert(node_id, offset); offset += dim; } } // Collect edges for edge_id in graph.edge_ids() { if let Some(edge) = graph.get_edge(edge_id) { edges.push((edge.source, edge.target, edge.weight as f64)); } } Self { config, edges, vertices, total_dim: offset, vertex_to_idx, vertex_to_offset, } } /// Create from a Sheaf and edge list pub fn from_sheaf( sheaf: &Sheaf, edges: Vec<(NodeId, NodeId, f64)>, config: LaplacianConfig, ) -> Self { let mut vertices = Vec::new(); let mut vertex_to_idx = HashMap::new(); let mut vertex_to_offset = HashMap::new(); let mut offset = 0; for (idx, vertex) in sheaf.vertices().enumerate() { if let Some(dim) = sheaf.stalk_dim(vertex) { vertices.push((vertex, dim)); vertex_to_idx.insert(vertex, idx); vertex_to_offset.insert(vertex, offset); offset += dim; } } Self { config, edges, vertices, total_dim: offset, vertex_to_idx, vertex_to_offset, } } /// Get total dimension pub fn dimension(&self) -> usize { self.total_dim } /// Build the Laplacian matrix explicitly /// /// L = sum_e w_e (P_s - P_t)^T D_e (P_s - P_t) /// where P_s, P_t are projection/restriction operators and D_e is edge weight pub fn build_matrix(&self, graph: &SheafGraph) -> Array2 { let n = self.total_dim; let mut laplacian = Array2::zeros((n, n)); for &(source, target, weight) in &self.edges { let source_offset = self.vertex_to_offset.get(&source).copied().unwrap_or(0); let target_offset = self.vertex_to_offset.get(&target).copied().unwrap_or(0); if let Some(edge) = graph.edge_ids().into_iter().find_map(|eid| { let e = graph.get_edge(eid)?; if e.source == source && e.target == target { Some(e) } else if e.source == target && e.target == source { Some(e) } else { None } }) { let source_dim = self .vertices .iter() .find(|(v, _)| *v == source) .map(|(_, d)| *d) .unwrap_or(0); let target_dim = self .vertices .iter() .find(|(v, _)| *v == target) .map(|(_, d)| *d) .unwrap_or(0); // For identity restrictions, the Laplacian contribution is: // L_ss += w_e * I // L_tt += w_e * I // L_st = L_ts = -w_e * I let dim = source_dim.min(target_dim); for i in 0..dim { // Diagonal blocks laplacian[[source_offset + i, source_offset + i]] += weight; laplacian[[target_offset + i, target_offset + i]] += weight; // Off-diagonal blocks laplacian[[source_offset + i, target_offset + i]] -= weight; laplacian[[target_offset + i, source_offset + i]] -= weight; } } } laplacian } /// Apply the Laplacian to a section (matrix-free) /// /// L * x = sum_e w_e * (rho_s(x_s) - rho_t(x_t))^2 pub fn apply(&self, graph: &SheafGraph, section: &SheafSection) -> SheafSection { let mut result = SheafSection::empty(); // Initialize result with zeros for (vertex, dim) in &self.vertices { result.set(*vertex, Array1::zeros(*dim)); } // Add contributions from each edge for &(source, target, weight) in &self.edges { if let (Some(s_val), Some(t_val)) = (section.get(source), section.get(target)) { // Residual = s_val - t_val (for identity restrictions) let residual = s_val - t_val; // Update source: add weight * residual if let Some(result_s) = result.sections.get_mut(&source) { *result_s = &*result_s + &(&residual * weight); } // Update target: add weight * (-residual) if let Some(result_t) = result.sections.get_mut(&target) { *result_t = &*result_t - &(&residual * weight); } } } result } /// Compute the quadratic form x^T L x (the energy) pub fn energy(&self, graph: &SheafGraph, section: &SheafSection) -> f64 { let mut energy = 0.0; for &(source, target, weight) in &self.edges { if let (Some(s_val), Some(t_val)) = (section.get(source), section.get(target)) { let residual = s_val - t_val; let norm_sq: f64 = residual.iter().map(|x| x * x).sum(); energy += weight * norm_sq; } } energy } /// Compute the spectrum using power iteration pub fn compute_spectrum(&self, graph: &SheafGraph) -> LaplacianSpectrum { let matrix = self.build_matrix(graph); self.compute_spectrum_from_matrix(&matrix) } /// Compute spectrum from explicit matrix fn compute_spectrum_from_matrix(&self, matrix: &Array2) -> LaplacianSpectrum { let n = matrix.nrows(); if n == 0 { return LaplacianSpectrum { eigenvalues: Vec::new(), eigenvectors: None, null_space_dim: 0, spectral_gap: None, }; } // Simple power iteration for largest eigenvalues, then deflation // For production, use proper eigenvalue solvers (LAPACK, etc.) let num_eigs = self.config.num_eigenvalues.min(n); let mut eigenvalues = Vec::with_capacity(num_eigs); let mut eigenvectors = if self.config.compute_eigenvectors { Some(Vec::with_capacity(num_eigs)) } else { None }; let mut deflated = matrix.clone(); for _ in 0..num_eigs { let (eval, evec) = self.power_iteration(&deflated); eigenvalues.push(eval); if self.config.compute_eigenvectors { eigenvectors.as_mut().unwrap().push(evec.clone()); } // Deflate: A <- A - lambda * v * v^T for i in 0..n { for j in 0..n { deflated[[i, j]] -= eval * evec[i] * evec[j]; } } } // Count zero eigenvalues let null_space_dim = eigenvalues .iter() .filter(|&&e| e.abs() < self.config.zero_tolerance) .count(); // Find spectral gap let spectral_gap = eigenvalues .iter() .find(|&&e| e > self.config.zero_tolerance) .copied(); LaplacianSpectrum { eigenvalues, eigenvectors, null_space_dim, spectral_gap, } } /// Power iteration for dominant eigenvalue fn power_iteration(&self, matrix: &Array2) -> (f64, Array1) { let n = matrix.nrows(); let mut v = Array1::from_elem(n, 1.0 / (n as f64).sqrt()); let mut eigenvalue = 0.0; for _ in 0..self.config.max_iterations { // Multiply by matrix let mut av = Array1::zeros(n); for i in 0..n { for j in 0..n { av[i] += matrix[[i, j]] * v[j]; } } // Compute Rayleigh quotient let new_eigenvalue: f64 = v.iter().zip(av.iter()).map(|(a, b)| a * b).sum(); // Normalize let norm = av.iter().map(|x| x * x).sum::().sqrt(); if norm > 1e-10 { v = av / norm; } // Check convergence if (new_eigenvalue - eigenvalue).abs() < self.config.zero_tolerance { eigenvalue = new_eigenvalue; break; } eigenvalue = new_eigenvalue; } (eigenvalue, v) } /// Find harmonic representatives (kernel of Laplacian) pub fn harmonic_representatives(&self, graph: &SheafGraph) -> Vec { let spectrum = self.compute_spectrum(graph); let mut harmonics = Vec::new(); if let Some(ref eigenvectors) = spectrum.eigenvectors { for (i, eval) in spectrum.eigenvalues.iter().enumerate() { if eval.abs() < self.config.zero_tolerance { // Convert eigenvector to section format let evec = &eigenvectors[i]; let mut cochain = HashMap::new(); for (vertex, dim) in &self.vertices { let offset = self.vertex_to_offset.get(vertex).copied().unwrap_or(0); let values = Array1::from_iter((0..*dim).map(|j| evec[offset + j])); cochain.insert(*vertex, values); } harmonics.push(HarmonicRepresentative::new(cochain, *eval)); } } } harmonics } } #[cfg(test)] mod tests { use super::*; use crate::substrate::edge::SheafEdgeBuilder; use crate::substrate::node::SheafNodeBuilder; use uuid::Uuid; fn make_node_id() -> NodeId { Uuid::new_v4() } #[test] fn test_laplacian_simple() { let graph = SheafGraph::new(); // Two nodes with same state let node1 = SheafNodeBuilder::new() .state_from_slice(&[1.0, 0.0]) .build(); let node2 = SheafNodeBuilder::new() .state_from_slice(&[1.0, 0.0]) .build(); let id1 = graph.add_node(node1); let id2 = graph.add_node(node2); let edge = SheafEdgeBuilder::new(id1, id2) .identity_restrictions(2) .weight(1.0) .build(); graph.add_edge(edge).unwrap(); let config = LaplacianConfig::default(); let laplacian = SheafLaplacian::from_graph(&graph, config); // Build and check matrix let matrix = laplacian.build_matrix(&graph); assert_eq!(matrix.nrows(), 4); // 2 nodes * 2 dimensions // Laplacian should be positive semi-definite let spectrum = laplacian.compute_spectrum(&graph); for eval in &spectrum.eigenvalues { assert!(*eval >= -1e-10); } } #[test] fn test_laplacian_energy() { let graph = SheafGraph::new(); let node1 = SheafNodeBuilder::new().state_from_slice(&[1.0]).build(); let node2 = SheafNodeBuilder::new().state_from_slice(&[2.0]).build(); let id1 = graph.add_node(node1); let id2 = graph.add_node(node2); let edge = SheafEdgeBuilder::new(id1, id2) .identity_restrictions(1) .weight(1.0) .build(); graph.add_edge(edge).unwrap(); let config = LaplacianConfig::default(); let laplacian = SheafLaplacian::from_graph(&graph, config); // Create section from graph states let mut section = SheafSection::empty(); section.set(id1, Array1::from_vec(vec![1.0])); section.set(id2, Array1::from_vec(vec![2.0])); // Energy = |1 - 2|^2 = 1 let energy = laplacian.energy(&graph, §ion); assert!((energy - 1.0).abs() < 1e-10); } #[test] fn test_connected_graph_has_one_zero_eigenvalue() { let graph = SheafGraph::new(); let node1 = SheafNodeBuilder::new().state_from_slice(&[1.0]).build(); let node2 = SheafNodeBuilder::new().state_from_slice(&[1.0]).build(); let node3 = SheafNodeBuilder::new().state_from_slice(&[1.0]).build(); let id1 = graph.add_node(node1); let id2 = graph.add_node(node2); let id3 = graph.add_node(node3); // Create a path: 1 -- 2 -- 3 let edge1 = SheafEdgeBuilder::new(id1, id2) .identity_restrictions(1) .weight(1.0) .build(); let edge2 = SheafEdgeBuilder::new(id2, id3) .identity_restrictions(1) .weight(1.0) .build(); graph.add_edge(edge1).unwrap(); graph.add_edge(edge2).unwrap(); let config = LaplacianConfig { num_eigenvalues: 3, ..Default::default() }; let laplacian = SheafLaplacian::from_graph(&graph, config); let spectrum = laplacian.compute_spectrum(&graph); // Connected graph should have exactly one zero eigenvalue // (corresponding to constant functions) assert_eq!(spectrum.null_space_dim, 1); } }