Skip to main content

batuta/tui/
graph_analytics.rs

1//! Graph Analytics
2//!
3//! Analytics algorithms for graph analysis including PageRank, community detection,
4//! and centrality metrics.
5//!
6//! ## Academic References
7//!
8//! - Page et al. (1999) "The PageRank Citation Ranking"
9//! - Blondel et al. (2008) "Fast unfolding of communities in large networks"
10//! - Brandes (2001) "A Faster Algorithm for Betweenness Centrality"
11
12use std::collections::HashMap;
13
14use super::graph::Graph;
15
16/// Community color palette for Louvain visualization
17/// Colors chosen for maximum distinguishability (per Healey's preattentive research)
18pub const COMMUNITY_COLORS: [&str; 8] = [
19    "\x1b[31m", // Red
20    "\x1b[32m", // Green
21    "\x1b[33m", // Yellow
22    "\x1b[34m", // Blue
23    "\x1b[35m", // Magenta
24    "\x1b[36m", // Cyan
25    "\x1b[91m", // Bright Red
26    "\x1b[92m", // Bright Green
27];
28
29/// Graph analytics engine for PageRank and community detection
30pub struct GraphAnalytics;
31
32impl GraphAnalytics {
33    /// Compute PageRank scores and apply as node importance
34    ///
35    /// Based on Page et al. (1999) "The PageRank Citation Ranking"
36    ///
37    /// # Arguments
38    /// * `graph` - Graph to analyze
39    /// * `damping` - Damping factor (default: 0.85)
40    /// * `iterations` - Max iterations (default: 20)
41    ///
42    /// # Returns
43    /// HashMap of node_id -> PageRank score
44    pub fn pagerank<N, E>(
45        graph: &Graph<N, E>,
46        damping: f32,
47        iterations: usize,
48    ) -> HashMap<String, f32> {
49        let n = graph.node_count();
50        if n == 0 {
51            return HashMap::new();
52        }
53
54        let teleport = (1.0 - damping) / n as f32;
55        let node_ids: Vec<String> = graph.nodes.keys().cloned().collect();
56
57        // Initialize: uniform distribution
58        let mut ranks: HashMap<String, f32> =
59            node_ids.iter().map(|id| (id.clone(), 1.0 / n as f32)).collect();
60
61        // Compute out-degrees
62        let out_degrees: HashMap<String, usize> =
63            node_ids.iter().map(|id| (id.clone(), graph.neighbors(id).len())).collect();
64
65        // Power iteration
66        for _ in 0..iterations {
67            let mut new_ranks: HashMap<String, f32> =
68                node_ids.iter().map(|id| (id.clone(), teleport)).collect();
69
70            // Distribute rank along edges
71            for edge in graph.edges() {
72                let out_deg = *out_degrees.get(&edge.from).unwrap_or(&1);
73                if out_deg > 0 {
74                    let contribution =
75                        damping * ranks.get(&edge.from).unwrap_or(&0.0) / out_deg as f32;
76                    *new_ranks.entry(edge.to.clone()).or_default() += contribution;
77                }
78            }
79
80            // Handle dangling nodes (no outgoing edges)
81            let dangling_sum: f32 = node_ids
82                .iter()
83                .filter(|id| *out_degrees.get(*id).unwrap_or(&0) == 0)
84                .map(|id| ranks.get(id).unwrap_or(&0.0))
85                .sum();
86
87            let dangling_contribution = damping * dangling_sum / n as f32;
88            for rank in new_ranks.values_mut() {
89                *rank += dangling_contribution;
90            }
91
92            ranks = new_ranks;
93        }
94
95        // Normalize to [0, 1]
96        let max_rank = ranks.values().copied().fold(0.0_f32, f32::max);
97        if max_rank > 0.0 {
98            for rank in ranks.values_mut() {
99                *rank /= max_rank;
100            }
101        }
102
103        ranks
104    }
105
106    /// Apply PageRank scores as node importance
107    pub fn apply_pagerank<N, E>(graph: &mut Graph<N, E>, damping: f32, iterations: usize) {
108        let ranks = Self::pagerank(graph, damping, iterations);
109        for (id, rank) in ranks {
110            if let Some(node) = graph.get_node_mut(&id) {
111                node.importance = rank;
112            }
113        }
114    }
115
116    /// Detect communities using Louvain-style modularity optimization
117    ///
118    /// Based on Blondel et al. (2008) "Fast unfolding of communities in large networks"
119    ///
120    /// Simplified implementation suitable for TUI visualization.
121    /// For production use, prefer trueno-graph's full Louvain implementation.
122    ///
123    /// # Returns
124    /// HashMap of node_id -> community_id
125    pub fn detect_communities<N, E>(graph: &Graph<N, E>) -> HashMap<String, usize> {
126        let node_ids: Vec<String> = graph.nodes.keys().cloned().collect();
127        let n = node_ids.len();
128
129        if n == 0 {
130            return HashMap::new();
131        }
132
133        // Initialize: each node in its own community
134        let mut communities: HashMap<String, usize> =
135            node_ids.iter().enumerate().map(|(i, id)| (id.clone(), i)).collect();
136
137        // Build adjacency for quick lookup
138        let mut adjacency: HashMap<String, Vec<String>> = HashMap::new();
139        for edge in graph.edges() {
140            adjacency.entry(edge.from.clone()).or_default().push(edge.to.clone());
141            // For undirected treatment
142            adjacency.entry(edge.to.clone()).or_default().push(edge.from.clone());
143        }
144
145        // Greedy modularity optimization (simplified)
146        let mut improved = true;
147        let mut max_iterations = 10;
148
149        while improved && max_iterations > 0 {
150            improved = false;
151            max_iterations -= 1;
152
153            for node_id in &node_ids {
154                let current_comm = *communities.get(node_id).unwrap_or(&0);
155
156                // Find best community among neighbors
157                let neighbors = adjacency.get(node_id).cloned().unwrap_or_default();
158                let mut neighbor_comms: HashMap<usize, usize> = HashMap::new();
159
160                for neighbor in &neighbors {
161                    let comm = *communities.get(neighbor).unwrap_or(&0);
162                    *neighbor_comms.entry(comm).or_default() += 1;
163                }
164
165                // Move to community with most connections
166                if let Some((&best_comm, &count)) = neighbor_comms.iter().max_by_key(|(_, &c)| c) {
167                    if best_comm != current_comm && count > 1 {
168                        communities.insert(node_id.clone(), best_comm);
169                        improved = true;
170                    }
171                }
172            }
173        }
174
175        // Renumber communities to be contiguous (0, 1, 2, ...)
176        let mut comm_map: HashMap<usize, usize> = HashMap::new();
177        let mut next_comm = 0;
178
179        for comm in communities.values_mut() {
180            let new_comm = *comm_map.entry(*comm).or_insert_with(|| {
181                let c = next_comm;
182                next_comm += 1;
183                c
184            });
185            *comm = new_comm;
186        }
187
188        communities
189    }
190
191    /// Apply community detection and color nodes by community
192    pub fn apply_communities<N, E>(graph: &mut Graph<N, E>) -> usize {
193        let communities = Self::detect_communities(graph);
194        let num_communities = communities.values().max().map(|&m| m + 1).unwrap_or(0);
195
196        // Update node status based on community for visualization
197        for (id, comm) in &communities {
198            if let Some(node) = graph.get_node_mut(id) {
199                let comm_normalized =
200                    if num_communities > 0 { *comm as f32 / num_communities as f32 } else { 0.0 };
201                node.importance = f32::midpoint(node.importance, comm_normalized);
202            }
203        }
204
205        num_communities
206    }
207
208    /// Get community color for rendering
209    #[must_use]
210    pub fn community_color(community_id: usize) -> &'static str {
211        COMMUNITY_COLORS[community_id % COMMUNITY_COLORS.len()]
212    }
213
214    /// Compute degree centrality for all nodes
215    ///
216    /// Degree centrality = number of connections / (n-1)
217    #[must_use]
218    pub fn degree_centrality<N, E>(graph: &Graph<N, E>) -> HashMap<String, f32> {
219        let n = graph.node_count();
220        if n <= 1 {
221            return graph.nodes.keys().map(|id| (id.clone(), 0.0)).collect();
222        }
223
224        let mut in_degree: HashMap<String, usize> = HashMap::new();
225        let mut out_degree: HashMap<String, usize> = HashMap::new();
226
227        for id in graph.nodes.keys() {
228            in_degree.insert(id.clone(), 0);
229            out_degree.insert(id.clone(), 0);
230        }
231
232        for edge in &graph.edges {
233            *out_degree.entry(edge.from.clone()).or_default() += 1;
234            *in_degree.entry(edge.to.clone()).or_default() += 1;
235        }
236
237        graph
238            .nodes
239            .keys()
240            .map(|id| {
241                let total = in_degree.get(id).unwrap_or(&0) + out_degree.get(id).unwrap_or(&0);
242                (id.clone(), total as f32 / (n - 1) as f32)
243            })
244            .collect()
245    }
246
247    /// Compute betweenness centrality using Brandes algorithm
248    ///
249    /// Reference: Brandes (2001) "A Faster Algorithm for Betweenness Centrality"
250    #[must_use]
251    pub fn betweenness_centrality<N, E>(graph: &Graph<N, E>) -> HashMap<String, f32> {
252        let n = graph.node_count();
253        if n <= 2 {
254            return graph.nodes.keys().map(|id| (id.clone(), 0.0)).collect();
255        }
256
257        let mut betweenness: HashMap<String, f32> =
258            graph.nodes.keys().map(|id| (id.clone(), 0.0)).collect();
259        let node_ids: Vec<String> = graph.nodes.keys().cloned().collect();
260
261        // Build adjacency list
262        let mut adj: HashMap<String, Vec<String>> = HashMap::new();
263        for id in &node_ids {
264            adj.insert(id.clone(), Vec::new());
265        }
266        for edge in &graph.edges {
267            adj.entry(edge.from.clone()).or_default().push(edge.to.clone());
268        }
269
270        // Brandes algorithm
271        for s in &node_ids {
272            let mut stack: Vec<String> = Vec::new();
273            let mut pred: HashMap<String, Vec<String>> = HashMap::new();
274            let mut sigma: HashMap<String, f32> = HashMap::new();
275            let mut dist: HashMap<String, i32> = HashMap::new();
276
277            for id in &node_ids {
278                pred.insert(id.clone(), Vec::new());
279                sigma.insert(id.clone(), 0.0);
280                dist.insert(id.clone(), -1);
281            }
282            sigma.insert(s.clone(), 1.0);
283            dist.insert(s.clone(), 0);
284
285            let mut queue = std::collections::VecDeque::new();
286            queue.push_back(s.clone());
287
288            while let Some(v) = queue.pop_front() {
289                stack.push(v.clone());
290                let v_dist = *dist.get(&v).unwrap_or(&0);
291
292                for w in adj.get(&v).unwrap_or(&Vec::new()) {
293                    let w_dist = dist.get(w).unwrap_or(&-1);
294                    if *w_dist < 0 {
295                        queue.push_back(w.clone());
296                        dist.insert(w.clone(), v_dist + 1);
297                    }
298                    if *dist.get(w).unwrap_or(&0) == v_dist + 1 {
299                        let sigma_v = *sigma.get(&v).unwrap_or(&0.0);
300                        *sigma.entry(w.clone()).or_default() += sigma_v;
301                        pred.entry(w.clone()).or_default().push(v.clone());
302                    }
303                }
304            }
305
306            let mut delta: HashMap<String, f32> =
307                node_ids.iter().map(|id| (id.clone(), 0.0)).collect();
308            while let Some(w) = stack.pop() {
309                for v in pred.get(&w).unwrap_or(&Vec::new()) {
310                    let sigma_v = sigma.get(v).unwrap_or(&1.0);
311                    let sigma_w = sigma.get(&w).unwrap_or(&1.0);
312                    let delta_w = delta.get(&w).unwrap_or(&0.0);
313                    *delta.entry(v.clone()).or_default() += (sigma_v / sigma_w) * (1.0 + delta_w);
314                }
315                if &w != s {
316                    *betweenness.entry(w.clone()).or_default() += delta.get(&w).unwrap_or(&0.0);
317                }
318            }
319        }
320
321        // Normalize
322        let norm = if n > 2 { 2.0 / ((n - 1) * (n - 2)) as f32 } else { 1.0 };
323        for val in betweenness.values_mut() {
324            *val *= norm;
325        }
326
327        betweenness
328    }
329
330    /// Compute closeness centrality
331    ///
332    /// Closeness = (n-1) / sum of shortest path distances from node to all others.
333    #[must_use]
334    pub fn closeness_centrality<N, E>(graph: &Graph<N, E>) -> HashMap<String, f32> {
335        let n = graph.node_count();
336        if n <= 1 {
337            return graph.nodes.keys().map(|id| (id.clone(), 0.0)).collect();
338        }
339
340        let node_ids: Vec<String> = graph.nodes.keys().cloned().collect();
341
342        node_ids
343            .iter()
344            .map(|source| {
345                let distances = Self::bfs_distances(graph, source);
346                let reachable: Vec<i32> = distances.values().filter(|&&d| d > 0).copied().collect();
347
348                let closeness = if reachable.is_empty() {
349                    0.0
350                } else {
351                    let sum_dist: i32 = reachable.iter().sum();
352                    reachable.len() as f32 / sum_dist as f32
353                };
354
355                (source.clone(), closeness)
356            })
357            .collect()
358    }
359
360    /// Find shortest path between two nodes using BFS
361    #[must_use]
362    pub fn shortest_path<N, E>(graph: &Graph<N, E>, from: &str, to: &str) -> Option<Vec<String>> {
363        if from == to {
364            return Some(vec![from.to_string()]);
365        }
366
367        if !graph.nodes.contains_key(from) || !graph.nodes.contains_key(to) {
368            return None;
369        }
370
371        let mut visited: HashMap<String, String> = HashMap::new();
372        let mut queue = std::collections::VecDeque::new();
373        queue.push_back(from.to_string());
374        visited.insert(from.to_string(), String::new());
375
376        while let Some(current) = queue.pop_front() {
377            for neighbor in graph.neighbors(&current) {
378                if !visited.contains_key(neighbor) {
379                    visited.insert(neighbor.to_string(), current.clone());
380                    if neighbor == to {
381                        let mut path = vec![to.to_string()];
382                        let mut node = to.to_string();
383                        while let Some(pred) = visited.get(&node) {
384                            if pred.is_empty() {
385                                break;
386                            }
387                            path.push(pred.clone());
388                            node = pred.clone();
389                        }
390                        path.reverse();
391                        return Some(path);
392                    }
393                    queue.push_back(neighbor.to_string());
394                }
395            }
396        }
397
398        None
399    }
400
401    /// BFS distances from source to all reachable nodes
402    fn bfs_distances<N, E>(graph: &Graph<N, E>, source: &str) -> HashMap<String, i32> {
403        let mut dist: HashMap<String, i32> =
404            graph.nodes.keys().map(|id| (id.clone(), -1)).collect();
405        dist.insert(source.to_string(), 0);
406
407        let mut queue = std::collections::VecDeque::new();
408        queue.push_back(source.to_string());
409
410        while let Some(current) = queue.pop_front() {
411            let current_dist = *dist.get(&current).unwrap_or(&0);
412            for neighbor in graph.neighbors(&current) {
413                if *dist.get(neighbor).unwrap_or(&-1) < 0 {
414                    dist.insert(neighbor.to_string(), current_dist + 1);
415                    queue.push_back(neighbor.to_string());
416                }
417            }
418        }
419
420        dist
421    }
422
423    /// Check if graph is connected (weakly connected for directed graphs)
424    #[must_use]
425    pub fn is_connected<N, E>(graph: &Graph<N, E>) -> bool {
426        if graph.node_count() == 0 {
427            return true;
428        }
429
430        let mut adj: HashMap<String, Vec<String>> = HashMap::new();
431        for id in graph.nodes.keys() {
432            adj.insert(id.clone(), Vec::new());
433        }
434        for edge in &graph.edges {
435            adj.entry(edge.from.clone()).or_default().push(edge.to.clone());
436            adj.entry(edge.to.clone()).or_default().push(edge.from.clone());
437        }
438
439        let first = graph.nodes.keys().next().expect("graph is non-empty after check");
440        let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
441        let mut queue = std::collections::VecDeque::new();
442        queue.push_back(first.clone());
443        visited.insert(first.clone());
444
445        while let Some(current) = queue.pop_front() {
446            for neighbor in adj.get(&current).unwrap_or(&Vec::new()) {
447                if !visited.contains(neighbor) {
448                    visited.insert(neighbor.clone());
449                    queue.push_back(neighbor.clone());
450                }
451            }
452        }
453
454        visited.len() == graph.node_count()
455    }
456}
457
458/// Extended graph with analytics capabilities
459pub trait GraphAnalyticsExt<N, E> {
460    /// Compute and apply PageRank
461    fn compute_pagerank(&mut self, damping: f32, iterations: usize);
462
463    /// Detect and apply communities
464    fn detect_communities(&mut self) -> usize;
465
466    /// Get PageRank scores
467    fn pagerank_scores(&self) -> HashMap<String, f32>;
468}
469
470impl<N, E> GraphAnalyticsExt<N, E> for Graph<N, E> {
471    fn compute_pagerank(&mut self, damping: f32, iterations: usize) {
472        GraphAnalytics::apply_pagerank(self, damping, iterations);
473    }
474
475    fn detect_communities(&mut self) -> usize {
476        GraphAnalytics::apply_communities(self)
477    }
478
479    fn pagerank_scores(&self) -> HashMap<String, f32> {
480        GraphAnalytics::pagerank(self, 0.85, 20)
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use crate::tui::graph::Node;
488
489    #[test]
490    fn test_pagerank_empty_graph() {
491        let graph: Graph<(), ()> = Graph::new();
492        let ranks = GraphAnalytics::pagerank(&graph, 0.85, 20);
493        assert!(ranks.is_empty());
494    }
495
496    #[test]
497    fn test_pagerank_single_node() {
498        let mut graph: Graph<(), ()> = Graph::new();
499        graph.add_node(Node::new("a", ()));
500        let ranks = GraphAnalytics::pagerank(&graph, 0.85, 20);
501        assert_eq!(ranks.len(), 1);
502        assert!(ranks.contains_key("a"));
503    }
504
505    #[test]
506    fn test_community_detection_empty() {
507        let graph: Graph<(), ()> = Graph::new();
508        let communities = GraphAnalytics::detect_communities(&graph);
509        assert!(communities.is_empty());
510    }
511
512    #[test]
513    fn test_is_connected_empty() {
514        let graph: Graph<(), ()> = Graph::new();
515        assert!(GraphAnalytics::is_connected(&graph));
516    }
517
518    #[test]
519    fn test_community_color() {
520        let color = GraphAnalytics::community_color(0);
521        assert_eq!(color, "\x1b[31m"); // Red
522    }
523
524    #[test]
525    fn test_community_color_wraps() {
526        // Test that colors wrap around
527        let c0 = GraphAnalytics::community_color(0);
528        let c8 = GraphAnalytics::community_color(8);
529        assert_eq!(c0, c8); // Should wrap to same color
530    }
531
532    #[test]
533    fn test_degree_centrality_empty() {
534        let graph: Graph<(), ()> = Graph::new();
535        let centrality = GraphAnalytics::degree_centrality(&graph);
536        assert!(centrality.is_empty());
537    }
538
539    #[test]
540    fn test_degree_centrality_single_node() {
541        let mut graph: Graph<(), ()> = Graph::new();
542        graph.add_node(Node::new("a", ()));
543        let centrality = GraphAnalytics::degree_centrality(&graph);
544        assert_eq!(centrality.len(), 1);
545        assert_eq!(*centrality.get("a").expect("key not found"), 0.0);
546    }
547
548    #[test]
549    fn test_betweenness_centrality_empty() {
550        let graph: Graph<(), ()> = Graph::new();
551        let centrality = GraphAnalytics::betweenness_centrality(&graph);
552        assert!(centrality.is_empty());
553    }
554
555    #[test]
556    fn test_closeness_centrality_empty() {
557        let graph: Graph<(), ()> = Graph::new();
558        let centrality = GraphAnalytics::closeness_centrality(&graph);
559        assert!(centrality.is_empty());
560    }
561
562    #[test]
563    fn test_closeness_centrality_single_node() {
564        let mut graph: Graph<(), ()> = Graph::new();
565        graph.add_node(Node::new("a", ()));
566        let centrality = GraphAnalytics::closeness_centrality(&graph);
567        assert_eq!(centrality.len(), 1);
568        assert_eq!(*centrality.get("a").expect("key not found"), 0.0);
569    }
570
571    #[test]
572    fn test_shortest_path_same_node() {
573        let mut graph: Graph<(), ()> = Graph::new();
574        graph.add_node(Node::new("a", ()));
575        let path = GraphAnalytics::shortest_path(&graph, "a", "a");
576        assert_eq!(path, Some(vec!["a".to_string()]));
577    }
578
579    #[test]
580    fn test_shortest_path_nonexistent() {
581        let graph: Graph<(), ()> = Graph::new();
582        let path = GraphAnalytics::shortest_path(&graph, "a", "b");
583        assert!(path.is_none());
584    }
585
586    #[test]
587    fn test_is_connected_single_node() {
588        let mut graph: Graph<(), ()> = Graph::new();
589        graph.add_node(Node::new("a", ()));
590        assert!(GraphAnalytics::is_connected(&graph));
591    }
592
593    #[test]
594    fn test_graph_analytics_ext_trait() {
595        use crate::tui::graph::Edge;
596
597        let mut graph: Graph<(), ()> = Graph::new();
598        graph.add_node(Node::new("a", ()));
599        graph.add_node(Node::new("b", ()));
600        graph.add_edge(Edge::new("a", "b", ()));
601
602        // Test trait methods
603        graph.compute_pagerank(0.85, 10);
604        let num_communities = graph.detect_communities();
605        let scores = graph.pagerank_scores();
606
607        assert!(scores.contains_key("a"));
608        let _ = num_communities; // usize is always >= 0
609    }
610
611    #[test]
612    fn test_apply_pagerank() {
613        use crate::tui::graph::Edge;
614
615        let mut graph: Graph<(), ()> = Graph::new();
616        graph.add_node(Node::new("a", ()));
617        graph.add_node(Node::new("b", ()));
618        graph.add_edge(Edge::new("a", "b", ()));
619
620        GraphAnalytics::apply_pagerank(&mut graph, 0.85, 10);
621
622        // Both nodes should have importance > 0
623        assert!(graph.get_node("a").expect("unexpected failure").importance >= 0.0);
624        assert!(graph.get_node("b").expect("unexpected failure").importance >= 0.0);
625    }
626
627    #[test]
628    fn test_apply_communities() {
629        use crate::tui::graph::Edge;
630
631        let mut graph: Graph<(), ()> = Graph::new();
632        graph.add_node(Node::new("a", ()));
633        graph.add_node(Node::new("b", ()));
634        graph.add_edge(Edge::new("a", "b", ()));
635
636        let num_communities = GraphAnalytics::apply_communities(&mut graph);
637        assert!(num_communities >= 1);
638    }
639}