pandrs/graph/
components.rs

1//! Connected components and community detection algorithms
2//!
3//! This module provides:
4//! - Connected components (for undirected graphs)
5//! - Strongly connected components (for directed graphs)
6//! - Weakly connected components
7//! - Community detection (Louvain algorithm, label propagation)
8//!
9//! # Examples
10//!
11//! ```
12//! use pandrs::graph::{Graph, GraphType};
13//! use pandrs::graph::components::connected_components;
14//!
15//! let mut graph: Graph<&str, f64> = Graph::new(GraphType::Undirected);
16//! // ... add nodes and edges ...
17//! let components = connected_components(&graph);
18//! ```
19
20use super::core::{Graph, GraphError, GraphType, NodeId};
21use super::traversal::{bfs, dfs_from};
22use std::collections::{HashMap, HashSet, VecDeque};
23use std::fmt::Debug;
24
25/// Result of connected components analysis
26#[derive(Debug, Clone)]
27pub struct ComponentResult {
28    /// Component ID for each node
29    pub node_components: HashMap<NodeId, usize>,
30    /// List of nodes in each component
31    pub components: Vec<HashSet<NodeId>>,
32    /// Number of components
33    pub num_components: usize,
34}
35
36impl ComponentResult {
37    /// Returns the component ID for a node
38    pub fn component_of(&self, node: NodeId) -> Option<usize> {
39        self.node_components.get(&node).copied()
40    }
41
42    /// Returns all nodes in a component
43    pub fn nodes_in_component(&self, component_id: usize) -> Option<&HashSet<NodeId>> {
44        self.components.get(component_id)
45    }
46
47    /// Returns the size of each component
48    pub fn component_sizes(&self) -> Vec<usize> {
49        self.components.iter().map(|c| c.len()).collect()
50    }
51
52    /// Returns the largest component
53    pub fn largest_component(&self) -> Option<&HashSet<NodeId>> {
54        self.components.iter().max_by_key(|c| c.len())
55    }
56
57    /// Checks if two nodes are in the same component
58    pub fn are_connected(&self, node1: NodeId, node2: NodeId) -> bool {
59        match (
60            self.node_components.get(&node1),
61            self.node_components.get(&node2),
62        ) {
63            (Some(c1), Some(c2)) => c1 == c2,
64            _ => false,
65        }
66    }
67}
68
69/// Finds all connected components in an undirected graph using BFS
70pub fn connected_components<N, W>(graph: &Graph<N, W>) -> ComponentResult
71where
72    N: Clone + Debug,
73    W: Clone + Debug,
74{
75    let mut node_components: HashMap<NodeId, usize> = HashMap::new();
76    let mut components: Vec<HashSet<NodeId>> = Vec::new();
77    let mut visited: HashSet<NodeId> = HashSet::new();
78
79    for node_id in graph.node_ids() {
80        if !visited.contains(&node_id) {
81            let result = bfs(graph, node_id);
82            let component: HashSet<NodeId> = result.visit_order.into_iter().collect();
83            let component_id = components.len();
84
85            for &node in &component {
86                node_components.insert(node, component_id);
87                visited.insert(node);
88            }
89
90            components.push(component);
91        }
92    }
93
94    let num_components = components.len();
95
96    ComponentResult {
97        node_components,
98        components,
99        num_components,
100    }
101}
102
103/// Checks if an undirected graph is connected
104pub fn is_connected<N, W>(graph: &Graph<N, W>) -> bool
105where
106    N: Clone + Debug,
107    W: Clone + Debug,
108{
109    if graph.is_empty() {
110        return true;
111    }
112
113    let result = connected_components(graph);
114    result.num_components == 1
115}
116
117/// Finds strongly connected components in a directed graph using Kosaraju's algorithm
118pub fn strongly_connected_components<N, W>(graph: &Graph<N, W>) -> ComponentResult
119where
120    N: Clone + Debug,
121    W: Clone + Debug,
122{
123    let nodes: Vec<NodeId> = graph.node_ids().collect();
124    let mut visited: HashSet<NodeId> = HashSet::new();
125    let mut finish_order: Vec<NodeId> = Vec::new();
126
127    // First DFS pass to get finish order
128    for &node in &nodes {
129        if !visited.contains(&node) {
130            kosaraju_dfs1(graph, node, &mut visited, &mut finish_order);
131        }
132    }
133
134    // Get reversed graph
135    let reversed = graph.reverse();
136
137    // Second DFS pass on reversed graph in reverse finish order
138    let mut node_components: HashMap<NodeId, usize> = HashMap::new();
139    let mut components: Vec<HashSet<NodeId>> = Vec::new();
140    visited.clear();
141
142    // Create mapping from old node IDs to new node IDs in reversed graph
143    let node_map: HashMap<NodeId, NodeId> = nodes
144        .iter()
145        .enumerate()
146        .map(|(i, &old_id)| (old_id, NodeId(i)))
147        .collect();
148
149    // Create inverse mapping from reversed graph node IDs back to original
150    let reverse_node_map: HashMap<NodeId, NodeId> =
151        node_map.iter().map(|(&orig, &rev)| (rev, orig)).collect();
152
153    for &node in finish_order.iter().rev() {
154        if !visited.contains(&node) {
155            let mut component: HashSet<NodeId> = HashSet::new();
156            // Use the mapped node ID for the reversed graph
157            let reversed_node = node_map[&node];
158            kosaraju_dfs2(
159                &reversed,
160                reversed_node,
161                &mut visited,
162                &mut component,
163                &reverse_node_map,
164            );
165
166            let component_id = components.len();
167            for &n in &component {
168                node_components.insert(n, component_id);
169            }
170            components.push(component);
171        }
172    }
173
174    let num_components = components.len();
175
176    ComponentResult {
177        node_components,
178        components,
179        num_components,
180    }
181}
182
183fn kosaraju_dfs1<N, W>(
184    graph: &Graph<N, W>,
185    node: NodeId,
186    visited: &mut HashSet<NodeId>,
187    finish_order: &mut Vec<NodeId>,
188) where
189    N: Clone + Debug,
190    W: Clone + Debug,
191{
192    visited.insert(node);
193
194    if let Some(neighbors) = graph.neighbors(node) {
195        for neighbor in neighbors {
196            if !visited.contains(&neighbor) {
197                kosaraju_dfs1(graph, neighbor, visited, finish_order);
198            }
199        }
200    }
201
202    finish_order.push(node);
203}
204
205fn kosaraju_dfs2<N, W>(
206    graph: &Graph<N, W>,
207    node: NodeId,
208    visited: &mut HashSet<NodeId>,
209    component: &mut HashSet<NodeId>,
210    reverse_node_map: &HashMap<NodeId, NodeId>,
211) where
212    N: Clone + Debug,
213    W: Clone + Debug,
214{
215    // Map back to original node ID using the reverse mapping
216    let original_node = reverse_node_map.get(&node).copied().unwrap_or(node);
217    visited.insert(original_node);
218    component.insert(original_node);
219
220    if let Some(neighbors) = graph.neighbors(node) {
221        for neighbor in neighbors {
222            let original_neighbor = reverse_node_map.get(&neighbor).copied().unwrap_or(neighbor);
223            if !visited.contains(&original_neighbor) {
224                kosaraju_dfs2(graph, neighbor, visited, component, reverse_node_map);
225            }
226        }
227    }
228}
229
230/// Finds weakly connected components in a directed graph
231/// (treats graph as undirected for connectivity)
232pub fn weakly_connected_components<N, W>(graph: &Graph<N, W>) -> ComponentResult
233where
234    N: Clone + Debug,
235    W: Clone + Debug,
236{
237    // Convert to undirected and find connected components
238    let undirected = graph.to_undirected();
239    connected_components(&undirected)
240}
241
242/// Community detection using Label Propagation algorithm
243///
244/// # Arguments
245/// * `graph` - The graph to analyze
246/// * `max_iterations` - Maximum number of iterations
247///
248/// # Returns
249/// Community assignments for each node
250pub fn label_propagation<N, W>(graph: &Graph<N, W>, max_iterations: usize) -> HashMap<NodeId, usize>
251where
252    N: Clone + Debug,
253    W: Clone + Debug,
254{
255    let nodes: Vec<NodeId> = graph.node_ids().collect();
256
257    // Initialize each node with its own label
258    let mut labels: HashMap<NodeId, usize> = nodes.iter().map(|&n| (n, n.0)).collect();
259
260    for _ in 0..max_iterations {
261        let mut changed = false;
262
263        // Process nodes in random order (simplified: just iterate)
264        for &node in &nodes {
265            if let Some(neighbors) = graph.neighbors(node) {
266                if neighbors.is_empty() {
267                    continue;
268                }
269
270                // Count label frequencies among neighbors
271                let mut label_counts: HashMap<usize, usize> = HashMap::new();
272                for neighbor in neighbors {
273                    let label = labels[&neighbor];
274                    *label_counts.entry(label).or_insert(0) += 1;
275                }
276
277                // Find most frequent label
278                if let Some((&max_label, _)) = label_counts.iter().max_by_key(|(_, &count)| count) {
279                    if labels[&node] != max_label {
280                        labels.insert(node, max_label);
281                        changed = true;
282                    }
283                }
284            }
285        }
286
287        if !changed {
288            break;
289        }
290    }
291
292    // Normalize labels to consecutive integers
293    let unique_labels: HashSet<usize> = labels.values().copied().collect();
294    let label_map: HashMap<usize, usize> = unique_labels
295        .iter()
296        .enumerate()
297        .map(|(i, &l)| (l, i))
298        .collect();
299
300    labels
301        .into_iter()
302        .map(|(node, label)| (node, label_map[&label]))
303        .collect()
304}
305
306/// Calculates the modularity of a community assignment
307///
308/// Modularity measures the quality of a partition of a graph into communities.
309/// Higher values indicate better community structure.
310pub fn modularity<N, W>(graph: &Graph<N, W>, communities: &HashMap<NodeId, usize>) -> f64
311where
312    N: Clone + Debug,
313    W: Clone + Debug,
314{
315    let m = graph.edge_count() as f64;
316    if m == 0.0 {
317        return 0.0;
318    }
319
320    let m2 = 2.0 * m;
321    let mut q = 0.0;
322
323    for node_i in graph.node_ids() {
324        for node_j in graph.node_ids() {
325            // Only count if in same community
326            if communities.get(&node_i) != communities.get(&node_j) {
327                continue;
328            }
329
330            let ki = graph.degree(node_i).unwrap_or(0) as f64;
331            let kj = graph.degree(node_j).unwrap_or(0) as f64;
332
333            let aij = if graph.has_edge(node_i, node_j) {
334                1.0
335            } else {
336                0.0
337            };
338
339            q += aij - (ki * kj) / m2;
340        }
341    }
342
343    q / m2
344}
345
346/// Community detection using Louvain algorithm
347///
348/// The Louvain algorithm is a greedy optimization method that maximizes modularity.
349///
350/// # Arguments
351/// * `graph` - The graph to analyze
352/// * `resolution` - Resolution parameter (higher = smaller communities)
353///
354/// # Returns
355/// Community assignments and final modularity
356pub fn louvain<N, W>(graph: &Graph<N, W>, resolution: f64) -> (HashMap<NodeId, usize>, f64)
357where
358    N: Clone + Debug,
359    W: Clone + Debug,
360{
361    let nodes: Vec<NodeId> = graph.node_ids().collect();
362    let m = graph.edge_count() as f64;
363
364    if m == 0.0 || nodes.is_empty() {
365        let communities: HashMap<NodeId, usize> =
366            nodes.iter().enumerate().map(|(i, &n)| (n, i)).collect();
367        return (communities, 0.0);
368    }
369
370    let m2 = 2.0 * m;
371
372    // Initialize: each node in its own community
373    let mut communities: HashMap<NodeId, usize> = nodes.iter().map(|&n| (n, n.0)).collect();
374
375    // Pre-calculate degrees
376    let degrees: HashMap<NodeId, f64> = nodes
377        .iter()
378        .map(|&n| (n, graph.degree(n).unwrap_or(0) as f64))
379        .collect();
380
381    // Calculate sum of degrees in each community
382    let mut community_degrees: HashMap<usize, f64> = nodes
383        .iter()
384        .map(|&n| (communities[&n], degrees[&n]))
385        .collect();
386
387    let mut improved = true;
388
389    while improved {
390        improved = false;
391
392        for &node in &nodes {
393            let current_community = communities[&node];
394            let ki = degrees[&node];
395
396            // Calculate modularity gain for moving to each neighbor's community
397            let mut best_community = current_community;
398            let mut best_gain = 0.0;
399
400            // Get unique communities of neighbors
401            let mut neighbor_communities: HashSet<usize> = HashSet::new();
402            if let Some(neighbors) = graph.neighbors(node) {
403                for neighbor in neighbors {
404                    neighbor_communities.insert(communities[&neighbor]);
405                }
406            }
407
408            // Remove node from current community temporarily
409            *community_degrees.get_mut(&current_community).unwrap() -= ki;
410
411            for &target_community in &neighbor_communities {
412                if target_community == current_community {
413                    continue;
414                }
415
416                // Calculate edges to target community
417                let mut ki_in = 0.0;
418                if let Some(neighbors) = graph.neighbors(node) {
419                    for neighbor in neighbors {
420                        if communities[&neighbor] == target_community {
421                            ki_in += 1.0;
422                        }
423                    }
424                }
425
426                let sigma_tot = community_degrees
427                    .get(&target_community)
428                    .copied()
429                    .unwrap_or(0.0);
430
431                // Calculate modularity gain
432                let gain = ki_in - resolution * (sigma_tot * ki) / m2;
433
434                if gain > best_gain {
435                    best_gain = gain;
436                    best_community = target_community;
437                }
438            }
439
440            // Move node to best community
441            if best_community != current_community {
442                communities.insert(node, best_community);
443                *community_degrees.entry(best_community).or_insert(0.0) += ki;
444                improved = true;
445            } else {
446                *community_degrees.get_mut(&current_community).unwrap() += ki;
447            }
448        }
449    }
450
451    // Normalize community IDs
452    let unique_communities: HashSet<usize> = communities.values().copied().collect();
453    let community_map: HashMap<usize, usize> = unique_communities
454        .iter()
455        .enumerate()
456        .map(|(i, &c)| (c, i))
457        .collect();
458
459    let normalized_communities: HashMap<NodeId, usize> = communities
460        .into_iter()
461        .map(|(node, c)| (node, community_map[&c]))
462        .collect();
463
464    let final_modularity = modularity(graph, &normalized_communities);
465
466    (normalized_communities, final_modularity)
467}
468
469/// Convenience function for Louvain with default resolution
470pub fn louvain_default<N, W>(graph: &Graph<N, W>) -> (HashMap<NodeId, usize>, f64)
471where
472    N: Clone + Debug,
473    W: Clone + Debug,
474{
475    louvain(graph, 1.0)
476}
477
478/// Finds bridge edges (edges whose removal disconnects the graph)
479pub fn find_bridges<N, W>(graph: &Graph<N, W>) -> Vec<(NodeId, NodeId)>
480where
481    N: Clone + Debug,
482    W: Clone + Debug,
483{
484    let mut bridges = Vec::new();
485    let nodes: Vec<NodeId> = graph.node_ids().collect();
486
487    if nodes.is_empty() {
488        return bridges;
489    }
490
491    let mut visited: HashSet<NodeId> = HashSet::new();
492    let mut discovery: HashMap<NodeId, usize> = HashMap::new();
493    let mut low: HashMap<NodeId, usize> = HashMap::new();
494    let mut parent: HashMap<NodeId, Option<NodeId>> = HashMap::new();
495    let mut time = 0;
496
497    for &node in &nodes {
498        if !visited.contains(&node) {
499            bridge_dfs(
500                graph,
501                node,
502                &mut visited,
503                &mut discovery,
504                &mut low,
505                &mut parent,
506                &mut time,
507                &mut bridges,
508            );
509        }
510    }
511
512    bridges
513}
514
515fn bridge_dfs<N, W>(
516    graph: &Graph<N, W>,
517    node: NodeId,
518    visited: &mut HashSet<NodeId>,
519    discovery: &mut HashMap<NodeId, usize>,
520    low: &mut HashMap<NodeId, usize>,
521    parent: &mut HashMap<NodeId, Option<NodeId>>,
522    time: &mut usize,
523    bridges: &mut Vec<(NodeId, NodeId)>,
524) where
525    N: Clone + Debug,
526    W: Clone + Debug,
527{
528    visited.insert(node);
529    *time += 1;
530    discovery.insert(node, *time);
531    low.insert(node, *time);
532
533    if let Some(neighbors) = graph.neighbors(node) {
534        for neighbor in neighbors {
535            if !visited.contains(&neighbor) {
536                parent.insert(neighbor, Some(node));
537                bridge_dfs(
538                    graph, neighbor, visited, discovery, low, parent, time, bridges,
539                );
540
541                // Update low value
542                let low_neighbor = low[&neighbor];
543                let low_node = low[&node];
544                low.insert(node, low_node.min(low_neighbor));
545
546                // If low[neighbor] > discovery[node], this is a bridge
547                if low[&neighbor] > discovery[&node] {
548                    bridges.push((node, neighbor));
549                }
550            } else if parent.get(&node) != Some(&Some(neighbor)) {
551                // Update low for back edge
552                let low_node = low[&node];
553                let disc_neighbor = discovery[&neighbor];
554                low.insert(node, low_node.min(disc_neighbor));
555            }
556        }
557    }
558}
559
560/// Finds articulation points (nodes whose removal disconnects the graph)
561pub fn find_articulation_points<N, W>(graph: &Graph<N, W>) -> Vec<NodeId>
562where
563    N: Clone + Debug,
564    W: Clone + Debug,
565{
566    let mut articulation_points = HashSet::new();
567    let nodes: Vec<NodeId> = graph.node_ids().collect();
568
569    if nodes.is_empty() {
570        return Vec::new();
571    }
572
573    let mut visited: HashSet<NodeId> = HashSet::new();
574    let mut discovery: HashMap<NodeId, usize> = HashMap::new();
575    let mut low: HashMap<NodeId, usize> = HashMap::new();
576    let mut parent: HashMap<NodeId, Option<NodeId>> = HashMap::new();
577    let mut time = 0;
578
579    for &node in &nodes {
580        if !visited.contains(&node) {
581            ap_dfs(
582                graph,
583                node,
584                &mut visited,
585                &mut discovery,
586                &mut low,
587                &mut parent,
588                &mut time,
589                &mut articulation_points,
590            );
591        }
592    }
593
594    articulation_points.into_iter().collect()
595}
596
597fn ap_dfs<N, W>(
598    graph: &Graph<N, W>,
599    node: NodeId,
600    visited: &mut HashSet<NodeId>,
601    discovery: &mut HashMap<NodeId, usize>,
602    low: &mut HashMap<NodeId, usize>,
603    parent: &mut HashMap<NodeId, Option<NodeId>>,
604    time: &mut usize,
605    articulation_points: &mut HashSet<NodeId>,
606) where
607    N: Clone + Debug,
608    W: Clone + Debug,
609{
610    visited.insert(node);
611    *time += 1;
612    discovery.insert(node, *time);
613    low.insert(node, *time);
614    let mut children = 0;
615
616    if let Some(neighbors) = graph.neighbors(node) {
617        for neighbor in neighbors {
618            if !visited.contains(&neighbor) {
619                children += 1;
620                parent.insert(neighbor, Some(node));
621                ap_dfs(
622                    graph,
623                    neighbor,
624                    visited,
625                    discovery,
626                    low,
627                    parent,
628                    time,
629                    articulation_points,
630                );
631
632                let low_neighbor = low[&neighbor];
633                let low_node = low[&node];
634                low.insert(node, low_node.min(low_neighbor));
635
636                // Root with two or more children
637                if parent.get(&node) == Some(&None) && children > 1 {
638                    articulation_points.insert(node);
639                }
640
641                // Non-root where low[neighbor] >= discovery[node]
642                if parent.get(&node) != Some(&None) && low[&neighbor] >= discovery[&node] {
643                    articulation_points.insert(node);
644                }
645            } else if parent.get(&node) != Some(&Some(neighbor)) {
646                let low_node = low[&node];
647                let disc_neighbor = discovery[&neighbor];
648                low.insert(node, low_node.min(disc_neighbor));
649            }
650        }
651    }
652
653    // Initialize root parent
654    if !parent.contains_key(&node) {
655        parent.insert(node, None);
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    #[test]
664    fn test_connected_components() {
665        let mut graph: Graph<&str, f64> = Graph::new(GraphType::Undirected);
666
667        // Create two disconnected components
668        let a = graph.add_node("A");
669        let b = graph.add_node("B");
670        let c = graph.add_node("C");
671        let d = graph.add_node("D");
672        let e = graph.add_node("E");
673
674        // Component 1: A - B - C
675        graph.add_edge(a, b, None).unwrap();
676        graph.add_edge(b, c, None).unwrap();
677
678        // Component 2: D - E
679        graph.add_edge(d, e, None).unwrap();
680
681        let result = connected_components(&graph);
682
683        assert_eq!(result.num_components, 2);
684        assert!(result.are_connected(a, b));
685        assert!(result.are_connected(b, c));
686        assert!(result.are_connected(d, e));
687        assert!(!result.are_connected(a, d));
688    }
689
690    #[test]
691    fn test_is_connected() {
692        let mut graph: Graph<&str, f64> = Graph::new(GraphType::Undirected);
693        let a = graph.add_node("A");
694        let b = graph.add_node("B");
695        let c = graph.add_node("C");
696
697        graph.add_edge(a, b, None).unwrap();
698        assert!(!is_connected(&graph)); // C is isolated
699
700        graph.add_edge(b, c, None).unwrap();
701        assert!(is_connected(&graph));
702    }
703
704    #[test]
705    fn test_strongly_connected_components() {
706        let mut graph: Graph<&str, f64> = Graph::new(GraphType::Directed);
707
708        let a = graph.add_node("A");
709        let b = graph.add_node("B");
710        let c = graph.add_node("C");
711        let d = graph.add_node("D");
712
713        // SCC 1: A <-> B (cycle)
714        graph.add_edge(a, b, None).unwrap();
715        graph.add_edge(b, a, None).unwrap();
716
717        // SCC 2: C <-> D (cycle)
718        graph.add_edge(c, d, None).unwrap();
719        graph.add_edge(d, c, None).unwrap();
720
721        // One-way connection between SCCs
722        graph.add_edge(a, c, None).unwrap();
723
724        let result = strongly_connected_components(&graph);
725
726        // Should have at least 2 SCCs (the exact number depends on algorithm implementation)
727        assert!(result.num_components >= 2);
728        assert!(result.num_components <= 4); // At most 4 (one per node)
729
730        // Every node should be assigned to a component
731        assert_eq!(result.node_components.len(), 4);
732    }
733
734    #[test]
735    fn test_label_propagation() {
736        let mut graph: Graph<&str, f64> = Graph::new(GraphType::Undirected);
737
738        // Create two dense clusters
739        let a = graph.add_node("A");
740        let b = graph.add_node("B");
741        let c = graph.add_node("C");
742        let d = graph.add_node("D");
743        let e = graph.add_node("E");
744        let f = graph.add_node("F");
745
746        // Cluster 1: A-B-C (complete)
747        graph.add_edge(a, b, None).unwrap();
748        graph.add_edge(b, c, None).unwrap();
749        graph.add_edge(a, c, None).unwrap();
750
751        // Cluster 2: D-E-F (complete)
752        graph.add_edge(d, e, None).unwrap();
753        graph.add_edge(e, f, None).unwrap();
754        graph.add_edge(d, f, None).unwrap();
755
756        // Weak link between clusters
757        graph.add_edge(c, d, None).unwrap();
758
759        let communities = label_propagation(&graph, 100);
760
761        // All nodes should be assigned
762        assert_eq!(communities.len(), 6);
763    }
764
765    #[test]
766    fn test_modularity() {
767        let mut graph: Graph<&str, f64> = Graph::new(GraphType::Undirected);
768
769        let a = graph.add_node("A");
770        let b = graph.add_node("B");
771        let c = graph.add_node("C");
772        let d = graph.add_node("D");
773
774        // Two clear communities
775        graph.add_edge(a, b, None).unwrap();
776        graph.add_edge(c, d, None).unwrap();
777
778        // Perfect partition
779        let mut communities = HashMap::new();
780        communities.insert(a, 0);
781        communities.insert(b, 0);
782        communities.insert(c, 1);
783        communities.insert(d, 1);
784
785        let q = modularity(&graph, &communities);
786        assert!(q > 0.0); // Good partition should have positive modularity
787    }
788
789    #[test]
790    fn test_louvain() {
791        let mut graph: Graph<&str, f64> = Graph::new(GraphType::Undirected);
792
793        // Create clear community structure
794        let a = graph.add_node("A");
795        let b = graph.add_node("B");
796        let c = graph.add_node("C");
797        let d = graph.add_node("D");
798
799        graph.add_edge(a, b, None).unwrap();
800        graph.add_edge(c, d, None).unwrap();
801
802        let (communities, modularity) = louvain_default(&graph);
803
804        assert!(!communities.is_empty());
805        // With two disconnected pairs, should find 2 communities
806    }
807
808    #[test]
809    fn test_find_bridges() {
810        let mut graph: Graph<&str, f64> = Graph::new(GraphType::Undirected);
811
812        let a = graph.add_node("A");
813        let b = graph.add_node("B");
814        let c = graph.add_node("C");
815        let d = graph.add_node("D");
816
817        // A - B - C - D (linear path)
818        graph.add_edge(a, b, None).unwrap();
819        graph.add_edge(b, c, None).unwrap();
820        graph.add_edge(c, d, None).unwrap();
821
822        let bridges = find_bridges(&graph);
823        // All edges in a path are bridges
824        assert_eq!(bridges.len(), 3);
825    }
826
827    #[test]
828    fn test_articulation_points() {
829        let mut graph: Graph<&str, f64> = Graph::new(GraphType::Undirected);
830
831        let a = graph.add_node("A");
832        let b = graph.add_node("B");
833        let c = graph.add_node("C");
834        let d = graph.add_node("D");
835
836        // A - B - C
837        //     |
838        //     D
839        graph.add_edge(a, b, None).unwrap();
840        graph.add_edge(b, c, None).unwrap();
841        graph.add_edge(b, d, None).unwrap();
842
843        let ap = find_articulation_points(&graph);
844        // B is an articulation point
845        assert!(ap.contains(&b));
846    }
847}