//! Community Detection Metrics & Statistics //! //! Compute statistics for detected communities (Zep alignment). //! Modularity, density, cohesion metrics. //! //! CRAP: 14 (Graph metric calculations) //! SOLID: Single responsibility (metrics computation) //! DRY: Reuses community types from queries use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use tracing::debug; /// Community metrics configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MetricsConfig { pub enabled: bool, pub compute_modularity: bool, pub compute_density: bool, pub compute_cohesion: bool, } impl Default for MetricsConfig { fn default() -> Self { Self { enabled: true, compute_modularity: true, compute_density: true, compute_cohesion: true, } } } /// Community statistics #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CommunityMetrics { pub community_id: String, pub member_count: usize, pub edge_count: usize, // Metrics pub modularity: Option, // 0-1: higher = more cohesive pub density: Option, // 0-1: higher = more interconnected pub cohesion: Option, // 0-1: higher = stronger connections pub average_degree: f32, // Avg edges per node pub diameter: Option, // Max shortest path } /// Community metrics calculator pub struct CommunityMetricsCalculator { config: MetricsConfig, } impl CommunityMetricsCalculator { pub fn new(config: MetricsConfig) -> Self { Self { config } } /// Calculate modularity (range: -1 to 1, higher = better community structure) /// Simplified: how many edges are within community vs expected fn calculate_modularity( &self, members: &[String], edges: &[(String, String)], ) -> Option { if !self.config.compute_modularity || members.is_empty() { return None; } let member_set: HashSet<_> = members.iter().cloned().collect(); let member_count = members.len() as f32; // Count internal edges let internal_edges = edges .iter() .filter(|(a, b)| member_set.contains(a) && member_set.contains(b)) .count() as f32; // Expected edges in random network let total_possible = member_count * (member_count - 1.0) / 2.0; let edge_density = edges.len() as f32 / total_possible.max(1.0); // Modularity = (actual - expected) / total let expected_internal = edge_density * total_possible; let modularity = if total_possible > 0.0 { (internal_edges - expected_internal) / total_possible.max(1.0) } else { 0.0 }; Some(modularity.clamp(-1.0, 1.0)) } /// Calculate density (range: 0-1, ratio of edges to possible edges) fn calculate_density( &self, members: &[String], edges: &[(String, String)], ) -> Option { if !self.config.compute_density || members.len() < 2 { return None; } let member_set: HashSet<_> = members.iter().cloned().collect(); let member_count = members.len() as f32; // Count internal edges let internal_edges = edges .iter() .filter(|(a, b)| member_set.contains(a) && member_set.contains(b)) .count() as f32; // Max possible edges for undirected graph let max_edges = member_count * (member_count - 1.0) / 2.0; if max_edges > 0.0 { Some((internal_edges / max_edges).clamp(0.0, 1.0)) } else { Some(0.0) } } /// Calculate cohesion (average edge weight/strength) fn calculate_cohesion( &self, members: &[String], edges: &[(String, String)], edge_strengths: &[(String, String, f32)], ) -> Option { if !self.config.compute_cohesion || edges.is_empty() { return None; } let member_set: HashSet<_> = members.iter().cloned().collect(); // Average strength of internal edges let internal_strengths: Vec = edge_strengths .iter() .filter(|(a, b, _)| member_set.contains(a) && member_set.contains(b)) .map(|(_, _, strength)| *strength) .collect(); if internal_strengths.is_empty() { return Some(0.0); } let avg_strength = internal_strengths.iter().sum::() / internal_strengths.len() as f32; Some(avg_strength.clamp(0.0, 1.0)) } /// Calculate average degree fn calculate_average_degree( &self, members: &[String], edges: &[(String, String)], ) -> f32 { if members.is_empty() { return 0.0; } let member_set: HashSet<_> = members.iter().cloned().collect(); let mut degree_map: HashMap = members.iter().cloned().map(|m| (m, 0)).collect(); for (a, b) in edges { if member_set.contains(a) && member_set.contains(b) { *degree_map.entry(a.clone()).or_insert(0) += 1; *degree_map.entry(b.clone()).or_insert(0) += 1; } } let total_degree: usize = degree_map.values().sum(); total_degree as f32 / members.len() as f32 } /// Compute all metrics for a community pub fn compute( &self, community_id: &str, members: &[String], edges: &[(String, String)], edge_strengths: Option<&[(String, String, f32)]>, ) -> CommunityMetrics { debug!("Computing metrics for community: {} ({} members)", community_id, members.len()); let edge_count = edges.len(); let average_degree = self.calculate_average_degree(members, edges); let modularity = self.calculate_modularity(members, edges); let density = self.calculate_density(members, edges); let cohesion = edge_strengths.and_then(|es| self.calculate_cohesion(members, edges, es)); CommunityMetrics { community_id: community_id.to_string(), member_count: members.len(), edge_count, modularity, density, cohesion, average_degree, diameter: None, // TODO: implement BFS shortest path } } /// Rank communities by metric pub fn rank_by_metric( metrics: &[CommunityMetrics], metric: &str, ) -> Vec<&CommunityMetrics> { let mut sorted = metrics.iter().collect::>(); match metric { "modularity" => sorted.sort_by(|a, b| { b.modularity .partial_cmp(&a.modularity) .unwrap_or(std::cmp::Ordering::Equal) }), "density" => sorted.sort_by(|a, b| { b.density .partial_cmp(&a.density) .unwrap_or(std::cmp::Ordering::Equal) }), "cohesion" => sorted.sort_by(|a, b| { b.cohesion .partial_cmp(&a.cohesion) .unwrap_or(std::cmp::Ordering::Equal) }), "size" => sorted.sort_by(|a, b| b.member_count.cmp(&a.member_count)), "degree" => sorted.sort_by(|a, b| { b.average_degree .partial_cmp(&a.average_degree) .unwrap_or(std::cmp::Ordering::Equal) }), _ => {} } sorted } } #[cfg(test)] mod tests { use super::*; #[test] fn test_metrics_config_defaults() { let config = MetricsConfig::default(); assert!(config.enabled); assert!(config.compute_modularity); } #[test] fn test_calculate_density_full() { let config = MetricsConfig::default(); let calc = CommunityMetricsCalculator::new(config); let members = vec!["A".to_string(), "B".to_string(), "C".to_string()]; let edges = vec![ ("A".to_string(), "B".to_string()), ("B".to_string(), "C".to_string()), ("C".to_string(), "A".to_string()), ]; let density = calc.calculate_density(&members, &edges); assert!(density.is_some()); // Full graph: 3 edges / 3 possible = 1.0 assert_eq!(density.unwrap(), 1.0); } #[test] fn test_calculate_density_sparse() { let config = MetricsConfig::default(); let calc = CommunityMetricsCalculator::new(config); let members = vec!["A".to_string(), "B".to_string(), "C".to_string()]; let edges = vec![("A".to_string(), "B".to_string())]; // Only 1 edge let density = calc.calculate_density(&members, &edges); assert!(density.is_some()); // Sparse graph: 1 edge / 3 possible = 0.333... assert!(density.unwrap() < 0.5); } #[test] fn test_calculate_average_degree() { let config = MetricsConfig::default(); let calc = CommunityMetricsCalculator::new(config); let members = vec!["A".to_string(), "B".to_string(), "C".to_string()]; let edges = vec![ ("A".to_string(), "B".to_string()), ("B".to_string(), "C".to_string()), ]; let avg_degree = calc.calculate_average_degree(&members, &edges); // A: 1, B: 2, C: 1 → avg = 4/3 ≈ 1.33 assert!(avg_degree > 1.0 && avg_degree < 1.5); } #[test] fn test_compute_metrics() { let config = MetricsConfig::default(); let calc = CommunityMetricsCalculator::new(config); let members = vec!["A".to_string(), "B".to_string(), "C".to_string()]; let edges = vec![ ("A".to_string(), "B".to_string()), ("B".to_string(), "C".to_string()), ]; let metrics = calc.compute("community-1", &members, &edges, None); assert_eq!(metrics.community_id, "community-1"); assert_eq!(metrics.member_count, 3); assert_eq!(metrics.edge_count, 2); assert!(metrics.modularity.is_some()); assert!(metrics.density.is_some()); } #[test] fn test_rank_by_size() { let metrics = vec![ CommunityMetrics { community_id: "c1".to_string(), member_count: 5, edge_count: 0, modularity: None, density: None, cohesion: None, average_degree: 0.0, diameter: None, }, CommunityMetrics { community_id: "c2".to_string(), member_count: 10, edge_count: 0, modularity: None, density: None, cohesion: None, average_degree: 0.0, diameter: None, }, ]; let ranked = CommunityMetricsCalculator::rank_by_metric(&metrics, "size"); assert_eq!(ranked[0].community_id, "c2"); // Largest first assert_eq!(ranked[1].community_id, "c1"); } }