//! Hyperbolic Energy Computation //! //! Structures for representing depth-weighted coherence energy. use super::NodeId; use serde::{Deserialize, Serialize}; /// Result of computing a weighted residual for a single edge #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WeightedResidual { /// Source node ID pub source_id: NodeId, /// Target node ID pub target_id: NodeId, /// Depth of source node pub source_depth: f32, /// Depth of target node pub target_depth: f32, /// Depth-based weight multiplier pub depth_weight: f32, /// Squared norm of the residual vector pub residual_norm_sq: f32, /// Base weight from edge definition pub base_weight: f32, /// Final weighted energy: base_weight * residual_norm_sq * depth_weight pub weighted_energy: f32, } impl WeightedResidual { /// Get average depth of the edge pub fn avg_depth(&self) -> f32 { (self.source_depth + self.target_depth) / 2.0 } /// Get maximum depth pub fn max_depth(&self) -> f32 { self.source_depth.max(self.target_depth) } /// Get unweighted energy (without depth scaling) pub fn unweighted_energy(&self) -> f32 { self.base_weight * self.residual_norm_sq } /// Get depth contribution to energy pub fn depth_contribution(&self) -> f32 { self.weighted_energy - self.unweighted_energy() } } /// Aggregated hyperbolic coherence energy #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HyperbolicEnergy { /// Total weighted energy across all edges pub total_energy: f32, /// Per-edge weighted residuals pub edge_energies: Vec, /// Curvature used for computation pub curvature: f32, /// Maximum depth encountered pub max_depth: f32, /// Minimum depth encountered pub min_depth: f32, /// Number of edges pub num_edges: usize, } impl HyperbolicEnergy { /// Create empty energy pub fn empty() -> Self { Self { total_energy: 0.0, edge_energies: vec![], curvature: -1.0, max_depth: 0.0, min_depth: 0.0, num_edges: 0, } } /// Check if coherent (energy below threshold) pub fn is_coherent(&self, threshold: f32) -> bool { self.total_energy < threshold } /// Get average energy per edge pub fn avg_energy(&self) -> f32 { if self.num_edges == 0 { 0.0 } else { self.total_energy / self.num_edges as f32 } } /// Get average depth across all edges pub fn avg_depth(&self) -> f32 { if self.edge_energies.is_empty() { return 0.0; } let sum: f32 = self.edge_energies.iter().map(|e| e.avg_depth()).sum(); sum / self.edge_energies.len() as f32 } /// Get total unweighted energy (without depth scaling) pub fn total_unweighted_energy(&self) -> f32 { self.edge_energies .iter() .map(|e| e.unweighted_energy()) .sum() } /// Get depth contribution ratio pub fn depth_contribution_ratio(&self) -> f32 { let unweighted = self.total_unweighted_energy(); if unweighted < 1e-10 { return 1.0; } self.total_energy / unweighted } /// Find highest energy edge pub fn highest_energy_edge(&self) -> Option<&WeightedResidual> { self.edge_energies .iter() .max_by(|a, b| a.weighted_energy.partial_cmp(&b.weighted_energy).unwrap()) } /// Find deepest edge pub fn deepest_edge(&self) -> Option<&WeightedResidual> { self.edge_energies .iter() .max_by(|a, b| a.avg_depth().partial_cmp(&b.avg_depth()).unwrap()) } /// Get edges above energy threshold pub fn edges_above_threshold(&self, threshold: f32) -> Vec<&WeightedResidual> { self.edge_energies .iter() .filter(|e| e.weighted_energy > threshold) .collect() } /// Get edges at specific depth level pub fn edges_at_depth(&self, min_depth: f32, max_depth: f32) -> Vec<&WeightedResidual> { self.edge_energies .iter() .filter(|e| { let avg = e.avg_depth(); avg >= min_depth && avg < max_depth }) .collect() } /// Compute energy distribution by depth buckets pub fn energy_by_depth_buckets(&self, num_buckets: usize) -> Vec { if self.edge_energies.is_empty() || num_buckets == 0 { return vec![]; } let depth_range = self.max_depth - self.min_depth; let bucket_size = if depth_range > 0.0 { depth_range / num_buckets as f32 } else { 1.0 }; let mut buckets: Vec = (0..num_buckets) .map(|i| DepthBucketEnergy { bucket_index: i, depth_min: self.min_depth + i as f32 * bucket_size, depth_max: self.min_depth + (i + 1) as f32 * bucket_size, total_energy: 0.0, num_edges: 0, }) .collect(); for edge in &self.edge_energies { let avg_depth = edge.avg_depth(); let bucket_idx = ((avg_depth - self.min_depth) / bucket_size).floor() as usize; let bucket_idx = bucket_idx.min(num_buckets - 1); buckets[bucket_idx].total_energy += edge.weighted_energy; buckets[bucket_idx].num_edges += 1; } buckets } /// Merge with another HyperbolicEnergy pub fn merge(&mut self, other: HyperbolicEnergy) { self.total_energy += other.total_energy; self.edge_energies.extend(other.edge_energies); self.max_depth = self.max_depth.max(other.max_depth); self.min_depth = self.min_depth.min(other.min_depth); self.num_edges += other.num_edges; } } /// Energy aggregated by depth bucket #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DepthBucketEnergy { /// Bucket index (0 = shallowest) pub bucket_index: usize, /// Minimum depth in bucket pub depth_min: f32, /// Maximum depth in bucket pub depth_max: f32, /// Total energy in bucket pub total_energy: f32, /// Number of edges in bucket pub num_edges: usize, } impl DepthBucketEnergy { /// Get average energy per edge in bucket pub fn avg_energy(&self) -> f32 { if self.num_edges == 0 { 0.0 } else { self.total_energy / self.num_edges as f32 } } /// Get bucket midpoint depth pub fn midpoint_depth(&self) -> f32 { (self.depth_min + self.depth_max) / 2.0 } } #[cfg(test)] mod tests { use super::*; fn make_weighted_residual( source: NodeId, target: NodeId, source_depth: f32, target_depth: f32, energy: f32, ) -> WeightedResidual { WeightedResidual { source_id: source, target_id: target, source_depth, target_depth, depth_weight: 1.0 + (source_depth + target_depth).ln().max(0.0) / 2.0, residual_norm_sq: energy / 2.0, base_weight: 1.0, weighted_energy: energy, } } #[test] fn test_empty_energy() { let energy = HyperbolicEnergy::empty(); assert_eq!(energy.total_energy, 0.0); assert_eq!(energy.num_edges, 0); assert!(energy.is_coherent(1.0)); } #[test] fn test_energy_aggregation() { let edge1 = make_weighted_residual(1, 2, 0.5, 0.5, 0.1); let edge2 = make_weighted_residual(2, 3, 1.0, 1.5, 0.2); let edge3 = make_weighted_residual(3, 4, 2.0, 2.5, 0.3); let energy = HyperbolicEnergy { total_energy: 0.6, edge_energies: vec![edge1, edge2, edge3], curvature: -1.0, max_depth: 2.5, min_depth: 0.5, num_edges: 3, }; assert_eq!(energy.num_edges, 3); assert!((energy.avg_energy() - 0.2).abs() < 0.01); } #[test] fn test_highest_energy_edge() { let edge1 = make_weighted_residual(1, 2, 0.5, 0.5, 0.1); let edge2 = make_weighted_residual(2, 3, 1.0, 1.5, 0.5); // Highest let edge3 = make_weighted_residual(3, 4, 2.0, 2.5, 0.2); let energy = HyperbolicEnergy { total_energy: 0.8, edge_energies: vec![edge1, edge2, edge3], curvature: -1.0, max_depth: 2.5, min_depth: 0.5, num_edges: 3, }; let highest = energy.highest_energy_edge().unwrap(); assert_eq!(highest.source_id, 2); assert_eq!(highest.target_id, 3); } #[test] fn test_depth_buckets() { let edge1 = make_weighted_residual(1, 2, 0.5, 0.5, 0.1); let edge2 = make_weighted_residual(2, 3, 1.5, 1.5, 0.2); let edge3 = make_weighted_residual(3, 4, 2.5, 2.5, 0.3); let energy = HyperbolicEnergy { total_energy: 0.6, edge_energies: vec![edge1, edge2, edge3], curvature: -1.0, max_depth: 2.5, min_depth: 0.5, num_edges: 3, }; let buckets = energy.energy_by_depth_buckets(2); assert_eq!(buckets.len(), 2); // Shallow bucket should have edge1 assert_eq!(buckets[0].num_edges, 1); // Deep bucket should have edge2 and edge3 assert_eq!(buckets[1].num_edges, 2); } #[test] fn test_merge() { let mut energy1 = HyperbolicEnergy { total_energy: 0.5, edge_energies: vec![make_weighted_residual(1, 2, 0.5, 0.5, 0.5)], curvature: -1.0, max_depth: 0.5, min_depth: 0.5, num_edges: 1, }; let energy2 = HyperbolicEnergy { total_energy: 0.3, edge_energies: vec![make_weighted_residual(3, 4, 2.0, 2.0, 0.3)], curvature: -1.0, max_depth: 2.0, min_depth: 2.0, num_edges: 1, }; energy1.merge(energy2); assert!((energy1.total_energy - 0.8).abs() < 0.01); assert_eq!(energy1.num_edges, 2); assert_eq!(energy1.max_depth, 2.0); assert_eq!(energy1.min_depth, 0.5); } }