//! SheafEdge: Constraint between nodes with restriction maps //! //! An edge in the sheaf graph encodes a constraint between two nodes. //! The constraint is expressed via two restriction maps: //! //! - `rho_source`: Projects the source state to the shared comparison space //! - `rho_target`: Projects the target state to the shared comparison space //! //! The **residual** at an edge is the difference between these projections: //! ```text //! r_e = rho_source(x_source) - rho_target(x_target) //! ``` //! //! The **weighted residual energy** contributes to global coherence: //! ```text //! E_e = weight * ||r_e||^2 //! ``` //! //! # Performance Optimization //! //! Thread-local scratch buffers are used to eliminate per-edge allocations //! in hot paths. Use `residual_norm_squared_no_alloc` for allocation-free //! energy computation. use super::node::NodeId; use super::restriction::RestrictionMap; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::cell::RefCell; use std::collections::HashMap; use uuid::Uuid; /// Default initial capacity for scratch buffers const DEFAULT_SCRATCH_CAPACITY: usize = 256; /// Thread-local scratch buffers for allocation-free edge computations /// /// These buffers are reused across multiple edge energy calculations /// to avoid per-edge Vec allocations in hot paths. struct EdgeScratch { /// Buffer for projected source state projected_source: Vec, /// Buffer for projected target state projected_target: Vec, /// Buffer for residual vector (source - target) residual: Vec, } impl EdgeScratch { /// Create a new scratch buffer with the given initial capacity fn new(capacity: usize) -> Self { Self { projected_source: Vec::with_capacity(capacity), projected_target: Vec::with_capacity(capacity), residual: Vec::with_capacity(capacity), } } /// Ensure all buffers have at least the required capacity and set length /// /// This resizes the vectors to exactly `dim` elements, growing capacity /// if needed but never shrinking. #[inline] fn prepare(&mut self, dim: usize) { // Resize to exact dimension, reserving more capacity if needed if self.projected_source.capacity() < dim { self.projected_source .reserve(dim - self.projected_source.len()); } if self.projected_target.capacity() < dim { self.projected_target .reserve(dim - self.projected_target.len()); } if self.residual.capacity() < dim { self.residual.reserve(dim - self.residual.len()); } // Resize to exact length (fills with 0.0 if growing) self.projected_source.resize(dim, 0.0); self.projected_target.resize(dim, 0.0); self.residual.resize(dim, 0.0); } } thread_local! { /// Thread-local scratch buffers for edge computations static SCRATCH: RefCell = RefCell::new(EdgeScratch::new(DEFAULT_SCRATCH_CAPACITY)); } /// Unique identifier for an edge pub type EdgeId = Uuid; /// An edge encoding a constraint between two nodes #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SheafEdge { /// Unique edge identifier pub id: EdgeId, /// Source node identifier pub source: NodeId, /// Target node identifier pub target: NodeId, /// Weight for energy calculation (importance of this constraint) pub weight: f32, /// Restriction map from source to shared comparison space pub rho_source: RestrictionMap, /// Restriction map from target to shared comparison space pub rho_target: RestrictionMap, /// Edge type/label for filtering pub edge_type: Option, /// Namespace for multi-tenant isolation pub namespace: Option, /// Arbitrary metadata pub metadata: HashMap, /// Creation timestamp pub created_at: DateTime, /// Last update timestamp pub updated_at: DateTime, } impl SheafEdge { /// Create a new edge with identity restriction maps /// /// This means both source and target states must match exactly in the /// given dimension for the edge to be coherent. pub fn identity(source: NodeId, target: NodeId, dim: usize) -> Self { let now = Utc::now(); Self { id: Uuid::new_v4(), source, target, weight: 1.0, rho_source: RestrictionMap::identity(dim), rho_target: RestrictionMap::identity(dim), edge_type: None, namespace: None, metadata: HashMap::new(), created_at: now, updated_at: now, } } /// Create a new edge with custom restriction maps pub fn with_restrictions( source: NodeId, target: NodeId, rho_source: RestrictionMap, rho_target: RestrictionMap, ) -> Self { debug_assert_eq!( rho_source.output_dim(), rho_target.output_dim(), "Restriction maps must have same output dimension" ); let now = Utc::now(); Self { id: Uuid::new_v4(), source, target, weight: 1.0, rho_source, rho_target, edge_type: None, namespace: None, metadata: HashMap::new(), created_at: now, updated_at: now, } } /// Calculate the edge residual (local mismatch) /// /// The residual is the difference between the projected source and target states: /// ```text /// r_e = rho_source(x_source) - rho_target(x_target) /// ``` /// /// # SIMD Optimization /// /// The subtraction is performed using SIMD-friendly patterns. #[inline] pub fn residual(&self, source_state: &[f32], target_state: &[f32]) -> Vec { let projected_source = self.rho_source.apply(source_state); let projected_target = self.rho_target.apply(target_state); // SIMD-friendly subtraction projected_source .iter() .zip(projected_target.iter()) .map(|(&a, &b)| a - b) .collect() } /// Calculate the residual norm squared /// /// This is ||r_e||^2 without the weight factor. /// /// # SIMD Optimization /// /// Uses 4-lane accumulation for better vectorization. /// /// # Note /// /// This method allocates temporary vectors. For hot paths, prefer /// `residual_norm_squared_no_alloc` which uses thread-local scratch buffers. #[inline] pub fn residual_norm_squared(&self, source_state: &[f32], target_state: &[f32]) -> f32 { let residual = self.residual(source_state, target_state); // SIMD-friendly: process 4 elements at a time using chunks_exact let chunks = residual.chunks_exact(4); let remainder = chunks.remainder(); let mut acc = [0.0f32; 4]; for chunk in chunks { acc[0] += chunk[0] * chunk[0]; acc[1] += chunk[1] * chunk[1]; acc[2] += chunk[2] * chunk[2]; acc[3] += chunk[3] * chunk[3]; } let mut sum = acc[0] + acc[1] + acc[2] + acc[3]; for &r in remainder { sum += r * r; } sum } /// Calculate the residual norm squared without allocation /// /// This is ||r_e||^2 without the weight factor, using thread-local /// scratch buffers to avoid per-call allocations. /// /// # Performance /// /// This method is optimized for hot paths where many edges are processed /// in sequence. It reuses thread-local buffers to eliminate the 2-3 Vec /// allocations that would otherwise occur per edge. /// /// # SIMD Optimization /// /// Uses 4-lane accumulation for better vectorization. /// /// # Thread Safety /// /// Uses thread-local storage, so it's safe to call from multiple threads /// concurrently (each thread has its own scratch buffers). #[inline] pub fn residual_norm_squared_no_alloc( &self, source_state: &[f32], target_state: &[f32], ) -> f32 { let dim = self.comparison_dim(); SCRATCH.with(|scratch| { let mut scratch = scratch.borrow_mut(); scratch.prepare(dim); // Apply restriction maps into scratch buffers self.rho_source .apply_into(source_state, &mut scratch.projected_source); self.rho_target .apply_into(target_state, &mut scratch.projected_target); // Compute residual in-place: r = projected_source - projected_target for i in 0..dim { scratch.residual[i] = scratch.projected_source[i] - scratch.projected_target[i]; } // SIMD-friendly: compute norm squared with 4-lane accumulation let chunks = scratch.residual[..dim].chunks_exact(4); let remainder = chunks.remainder(); let mut acc = [0.0f32; 4]; for chunk in chunks { acc[0] += chunk[0] * chunk[0]; acc[1] += chunk[1] * chunk[1]; acc[2] += chunk[2] * chunk[2]; acc[3] += chunk[3] * chunk[3]; } let mut sum = acc[0] + acc[1] + acc[2] + acc[3]; for &r in remainder { sum += r * r; } sum }) } /// Calculate weighted residual energy without allocation /// /// This is the contribution of this edge to the global coherence energy: /// ```text /// E_e = weight * ||r_e||^2 /// ``` /// /// Uses thread-local scratch buffers to avoid per-call allocations. /// Preferred over `weighted_residual_energy` in hot paths. #[inline] pub fn weighted_residual_energy_no_alloc( &self, source_state: &[f32], target_state: &[f32], ) -> f32 { self.weight * self.residual_norm_squared_no_alloc(source_state, target_state) } /// Calculate weighted residual energy /// /// This is the contribution of this edge to the global coherence energy: /// ```text /// E_e = weight * ||r_e||^2 /// ``` #[inline] pub fn weighted_residual_energy(&self, source_state: &[f32], target_state: &[f32]) -> f32 { self.weight * self.residual_norm_squared(source_state, target_state) } /// Calculate residual energy and return both the energy and residual vector /// /// This is more efficient when you need both values. #[inline] pub fn residual_with_energy( &self, source_state: &[f32], target_state: &[f32], ) -> (Vec, f32) { let residual = self.residual(source_state, target_state); // SIMD-friendly: process 4 elements at a time using chunks_exact let chunks = residual.chunks_exact(4); let remainder = chunks.remainder(); let mut acc = [0.0f32; 4]; for chunk in chunks { acc[0] += chunk[0] * chunk[0]; acc[1] += chunk[1] * chunk[1]; acc[2] += chunk[2] * chunk[2]; acc[3] += chunk[3] * chunk[3]; } let mut norm_sq = acc[0] + acc[1] + acc[2] + acc[3]; for &r in remainder { norm_sq += r * r; } let energy = self.weight * norm_sq; (residual, energy) } /// Get the output dimension of the restriction maps (comparison space dimension) #[inline] pub fn comparison_dim(&self) -> usize { self.rho_source.output_dim() } /// Check if this edge is coherent (residual below threshold) #[inline] pub fn is_coherent(&self, source_state: &[f32], target_state: &[f32], threshold: f32) -> bool { self.residual_norm_squared(source_state, target_state) <= threshold * threshold } /// Update the weight pub fn set_weight(&mut self, weight: f32) { self.weight = weight; self.updated_at = Utc::now(); } /// Update the restriction maps pub fn set_restrictions(&mut self, rho_source: RestrictionMap, rho_target: RestrictionMap) { debug_assert_eq!( rho_source.output_dim(), rho_target.output_dim(), "Restriction maps must have same output dimension" ); self.rho_source = rho_source; self.rho_target = rho_target; self.updated_at = Utc::now(); } /// Compute content hash for fingerprinting pub fn content_hash(&self) -> u64 { use std::hash::{Hash, Hasher}; let mut hasher = std::collections::hash_map::DefaultHasher::new(); self.id.hash(&mut hasher); self.source.hash(&mut hasher); self.target.hash(&mut hasher); self.weight.to_bits().hash(&mut hasher); hasher.finish() } } /// Builder for constructing SheafEdge instances #[derive(Debug)] pub struct SheafEdgeBuilder { id: Option, source: NodeId, target: NodeId, weight: f32, rho_source: Option, rho_target: Option, edge_type: Option, namespace: Option, metadata: HashMap, } impl SheafEdgeBuilder { /// Create a new builder with required source and target nodes pub fn new(source: NodeId, target: NodeId) -> Self { Self { id: None, source, target, weight: 1.0, rho_source: None, rho_target: None, edge_type: None, namespace: None, metadata: HashMap::new(), } } /// Set a custom edge ID pub fn id(mut self, id: EdgeId) -> Self { self.id = Some(id); self } /// Set the weight pub fn weight(mut self, weight: f32) -> Self { self.weight = weight; self } /// Set both restriction maps to identity (states must match exactly) pub fn identity_restrictions(mut self, dim: usize) -> Self { self.rho_source = Some(RestrictionMap::identity(dim)); self.rho_target = Some(RestrictionMap::identity(dim)); self } /// Set the source restriction map pub fn rho_source(mut self, rho: RestrictionMap) -> Self { self.rho_source = Some(rho); self } /// Set the target restriction map pub fn rho_target(mut self, rho: RestrictionMap) -> Self { self.rho_target = Some(rho); self } /// Set both restriction maps at once pub fn restrictions(mut self, source: RestrictionMap, target: RestrictionMap) -> Self { debug_assert_eq!( source.output_dim(), target.output_dim(), "Restriction maps must have same output dimension" ); self.rho_source = Some(source); self.rho_target = Some(target); self } /// Set the edge type pub fn edge_type(mut self, edge_type: impl Into) -> Self { self.edge_type = Some(edge_type.into()); self } /// Set the namespace pub fn namespace(mut self, namespace: impl Into) -> Self { self.namespace = Some(namespace.into()); self } /// Add metadata pub fn metadata(mut self, key: impl Into, value: impl Into) -> Self { self.metadata.insert(key.into(), value.into()); self } /// Build the edge /// /// # Panics /// /// Panics if restriction maps were not provided. pub fn build(self) -> SheafEdge { let rho_source = self.rho_source.expect("Source restriction map is required"); let rho_target = self.rho_target.expect("Target restriction map is required"); debug_assert_eq!( rho_source.output_dim(), rho_target.output_dim(), "Restriction maps must have same output dimension" ); let now = Utc::now(); SheafEdge { id: self.id.unwrap_or_else(Uuid::new_v4), source: self.source, target: self.target, weight: self.weight, rho_source, rho_target, edge_type: self.edge_type, namespace: self.namespace, metadata: self.metadata, created_at: now, updated_at: now, } } /// Try to build the edge, returning an error if restrictions are missing pub fn try_build(self) -> Result { let rho_source = self .rho_source .ok_or("Source restriction map is required")?; let rho_target = self .rho_target .ok_or("Target restriction map is required")?; if rho_source.output_dim() != rho_target.output_dim() { return Err("Restriction maps must have same output dimension"); } let now = Utc::now(); Ok(SheafEdge { id: self.id.unwrap_or_else(Uuid::new_v4), source: self.source, target: self.target, weight: self.weight, rho_source, rho_target, edge_type: self.edge_type, namespace: self.namespace, metadata: self.metadata, created_at: now, updated_at: now, }) } } #[cfg(test)] mod tests { use super::*; fn make_test_nodes() -> (NodeId, NodeId) { (Uuid::new_v4(), Uuid::new_v4()) } #[test] fn test_identity_edge() { let (source, target) = make_test_nodes(); let edge = SheafEdge::identity(source, target, 3); assert_eq!(edge.source, source); assert_eq!(edge.target, target); assert_eq!(edge.weight, 1.0); assert_eq!(edge.comparison_dim(), 3); } #[test] fn test_identity_residual_matching() { let (source, target) = make_test_nodes(); let edge = SheafEdge::identity(source, target, 3); let source_state = vec![1.0, 2.0, 3.0]; let target_state = vec![1.0, 2.0, 3.0]; let residual = edge.residual(&source_state, &target_state); assert!(residual.iter().all(|&x| x.abs() < 1e-10)); assert!(edge.residual_norm_squared(&source_state, &target_state) < 1e-10); } #[test] fn test_identity_residual_mismatch() { let (source, target) = make_test_nodes(); let edge = SheafEdge::identity(source, target, 3); let source_state = vec![1.0, 2.0, 3.0]; let target_state = vec![2.0, 2.0, 3.0]; // Differs by 1 in first component let residual = edge.residual(&source_state, &target_state); assert_eq!(residual, vec![-1.0, 0.0, 0.0]); assert!((edge.residual_norm_squared(&source_state, &target_state) - 1.0).abs() < 1e-10); } #[test] fn test_weighted_energy() { let (source, target) = make_test_nodes(); let mut edge = SheafEdge::identity(source, target, 2); edge.set_weight(2.0); let source_state = vec![1.0, 0.0]; let target_state = vec![0.0, 0.0]; // Residual is [1, 0], norm^2 = 1 let energy = edge.weighted_residual_energy(&source_state, &target_state); assert!((energy - 2.0).abs() < 1e-10); // weight * 1 = 2 } #[test] fn test_projection_restriction() { let (source, target) = make_test_nodes(); // Source: 4D, project to first 2 dims // Target: 2D, identity let rho_source = RestrictionMap::projection(vec![0, 1], 4); let rho_target = RestrictionMap::identity(2); let edge = SheafEdge::with_restrictions(source, target, rho_source, rho_target); let source_state = vec![1.0, 2.0, 100.0, 200.0]; // Extra dims ignored let target_state = vec![1.0, 2.0]; let residual = edge.residual(&source_state, &target_state); assert!(residual.iter().all(|&x| x.abs() < 1e-10)); } #[test] fn test_diagonal_restriction() { let (source, target) = make_test_nodes(); // Source scaled by [2, 2], target by [1, 1] // For coherence: 2*source = 1*target, so source = target/2 let rho_source = RestrictionMap::diagonal(vec![2.0, 2.0]); let rho_target = RestrictionMap::identity(2); let edge = SheafEdge::with_restrictions(source, target, rho_source, rho_target); let source_state = vec![1.0, 1.0]; let target_state = vec![2.0, 2.0]; // 2*[1,1] = [2,2] assert!(edge.residual_norm_squared(&source_state, &target_state) < 1e-10); } #[test] fn test_is_coherent() { let (source, target) = make_test_nodes(); let edge = SheafEdge::identity(source, target, 2); let source_state = vec![1.0, 0.0]; let target_state = vec![1.1, 0.0]; // Small difference // Residual is [-0.1, 0], norm = 0.1 assert!(edge.is_coherent(&source_state, &target_state, 0.2)); // Below threshold assert!(!edge.is_coherent(&source_state, &target_state, 0.05)); // Above threshold } #[test] fn test_builder() { let (source, target) = make_test_nodes(); let edge = SheafEdgeBuilder::new(source, target) .weight(2.5) .identity_restrictions(4) .edge_type("citation") .namespace("test") .metadata("importance", serde_json::json!(0.9)) .build(); assert_eq!(edge.weight, 2.5); assert_eq!(edge.edge_type, Some("citation".to_string())); assert_eq!(edge.namespace, Some("test".to_string())); assert!(edge.metadata.contains_key("importance")); } #[test] fn test_residual_with_energy() { let (source, target) = make_test_nodes(); let edge = SheafEdge::identity(source, target, 3); let source_state = vec![1.0, 2.0, 3.0]; let target_state = vec![0.0, 0.0, 0.0]; let (residual, energy) = edge.residual_with_energy(&source_state, &target_state); assert_eq!(residual, vec![1.0, 2.0, 3.0]); assert!((energy - 14.0).abs() < 1e-10); // 1 + 4 + 9 = 14 } #[test] fn test_content_hash_stability() { let (source, target) = make_test_nodes(); let edge = SheafEdge::identity(source, target, 3); let hash1 = edge.content_hash(); let hash2 = edge.content_hash(); assert_eq!(hash1, hash2); } #[test] fn test_residual_norm_squared_no_alloc_identity() { let (source, target) = make_test_nodes(); let edge = SheafEdge::identity(source, target, 3); let source_state = vec![1.0, 2.0, 3.0]; let target_state = vec![1.0, 2.0, 3.0]; // Should match allocating version let alloc_result = edge.residual_norm_squared(&source_state, &target_state); let no_alloc_result = edge.residual_norm_squared_no_alloc(&source_state, &target_state); assert!((alloc_result - no_alloc_result).abs() < 1e-10); assert!(no_alloc_result < 1e-10); } #[test] fn test_residual_norm_squared_no_alloc_mismatch() { let (source, target) = make_test_nodes(); let edge = SheafEdge::identity(source, target, 3); let source_state = vec![1.0, 2.0, 3.0]; let target_state = vec![0.0, 0.0, 0.0]; // Residual is [1, 2, 3], norm^2 = 1 + 4 + 9 = 14 let alloc_result = edge.residual_norm_squared(&source_state, &target_state); let no_alloc_result = edge.residual_norm_squared_no_alloc(&source_state, &target_state); assert!((alloc_result - no_alloc_result).abs() < 1e-10); assert!((no_alloc_result - 14.0).abs() < 1e-10); } #[test] fn test_residual_norm_squared_no_alloc_with_projection() { let (source, target) = make_test_nodes(); // Source: 4D, project to first 2 dims let rho_source = RestrictionMap::projection(vec![0, 1], 4); let rho_target = RestrictionMap::identity(2); let edge = SheafEdge::with_restrictions(source, target, rho_source, rho_target); let source_state = vec![1.0, 2.0, 100.0, 200.0]; let target_state = vec![1.0, 2.0]; let alloc_result = edge.residual_norm_squared(&source_state, &target_state); let no_alloc_result = edge.residual_norm_squared_no_alloc(&source_state, &target_state); assert!((alloc_result - no_alloc_result).abs() < 1e-10); assert!(no_alloc_result < 1e-10); } #[test] fn test_residual_norm_squared_no_alloc_with_diagonal() { let (source, target) = make_test_nodes(); let rho_source = RestrictionMap::diagonal(vec![2.0, 2.0]); let rho_target = RestrictionMap::identity(2); let edge = SheafEdge::with_restrictions(source, target, rho_source, rho_target); let source_state = vec![1.0, 1.0]; let target_state = vec![2.0, 2.0]; let alloc_result = edge.residual_norm_squared(&source_state, &target_state); let no_alloc_result = edge.residual_norm_squared_no_alloc(&source_state, &target_state); assert!((alloc_result - no_alloc_result).abs() < 1e-10); assert!(no_alloc_result < 1e-10); } #[test] fn test_weighted_residual_energy_no_alloc() { let (source, target) = make_test_nodes(); let mut edge = SheafEdge::identity(source, target, 2); edge.set_weight(2.0); let source_state = vec![1.0, 0.0]; let target_state = vec![0.0, 0.0]; let alloc_result = edge.weighted_residual_energy(&source_state, &target_state); let no_alloc_result = edge.weighted_residual_energy_no_alloc(&source_state, &target_state); assert!((alloc_result - no_alloc_result).abs() < 1e-10); assert!((no_alloc_result - 2.0).abs() < 1e-10); } #[test] fn test_no_alloc_buffer_reuse() { // Test that scratch buffers are properly reused across multiple calls let (source, target) = make_test_nodes(); // First call with dim=3 let edge3 = SheafEdge::identity(source, target, 3); let result3 = edge3.residual_norm_squared_no_alloc(&[1.0, 2.0, 3.0], &[0.0, 0.0, 0.0]); assert!((result3 - 14.0).abs() < 1e-10); // Second call with larger dim=5 (buffers should grow) let edge5 = SheafEdge::identity(source, target, 5); let result5 = edge5 .residual_norm_squared_no_alloc(&[1.0, 2.0, 3.0, 4.0, 5.0], &[0.0, 0.0, 0.0, 0.0, 0.0]); assert!((result5 - 55.0).abs() < 1e-10); // 1 + 4 + 9 + 16 + 25 = 55 // Third call back to dim=3 (buffers should shrink length but keep capacity) let result3_again = edge3.residual_norm_squared_no_alloc(&[1.0, 2.0, 3.0], &[0.0, 0.0, 0.0]); assert!((result3_again - 14.0).abs() < 1e-10); } #[test] fn test_no_alloc_large_dimension() { // Test with dimension larger than default capacity (256) let (source, target) = make_test_nodes(); let dim = 512; let edge = SheafEdge::identity(source, target, dim); let source_state: Vec = (0..dim).map(|i| i as f32).collect(); let target_state: Vec = vec![0.0; dim]; let alloc_result = edge.residual_norm_squared(&source_state, &target_state); let no_alloc_result = edge.residual_norm_squared_no_alloc(&source_state, &target_state); assert!((alloc_result - no_alloc_result).abs() < 1e-4); } }