545 lines
17 KiB
Rust
545 lines
17 KiB
Rust
//! 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<f64>,
|
|
/// Eigenvectors (optional)
|
|
pub eigenvectors: Option<Vec<Array1<f64>>>,
|
|
/// Number of zero eigenvalues (cohomology dimension)
|
|
pub null_space_dim: usize,
|
|
/// Spectral gap (smallest positive eigenvalue)
|
|
pub spectral_gap: Option<f64>,
|
|
}
|
|
|
|
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<f64>> {
|
|
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<NodeId, Array1<f64>>,
|
|
/// 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<NodeId, Array1<f64>>, eigenvalue: f64) -> Self {
|
|
let norm = cochain
|
|
.values()
|
|
.map(|v| v.iter().map(|x| x * x).sum::<f64>())
|
|
.sum::<f64>()
|
|
.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<NodeId, usize>,
|
|
/// Vertex to offset in global vector
|
|
vertex_to_offset: HashMap<NodeId, usize>,
|
|
}
|
|
|
|
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<f64> {
|
|
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<f64>) -> 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>) -> (f64, Array1<f64>) {
|
|
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::<f64>().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<HarmonicRepresentative> {
|
|
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);
|
|
}
|
|
}
|