Skip to main content

graphos_adapters/plugins/algorithms/
community.rs

1//! Community detection algorithms: Louvain, Label Propagation.
2//!
3//! These algorithms identify clusters or communities of nodes that are
4//! more densely connected to each other than to the rest of the graph.
5
6use std::sync::OnceLock;
7
8use graphos_common::types::{NodeId, Value};
9use graphos_common::utils::error::Result;
10use graphos_common::utils::hash::{FxHashMap, FxHashSet};
11use graphos_core::graph::Direction;
12use graphos_core::graph::lpg::LpgStore;
13
14use super::super::{AlgorithmResult, ParameterDef, ParameterType, Parameters};
15use super::traits::{ComponentResultBuilder, GraphAlgorithm};
16
17// ============================================================================
18// Label Propagation
19// ============================================================================
20
21/// Detects communities using the Label Propagation Algorithm.
22///
23/// Each node is initially assigned a unique label. Then, iteratively,
24/// each node adopts the most frequent label among its neighbors until
25/// the labels stabilize.
26///
27/// # Arguments
28///
29/// * `store` - The graph store
30/// * `max_iterations` - Maximum number of iterations (0 for unlimited)
31///
32/// # Returns
33///
34/// A map from node ID to community (label) ID.
35///
36/// # Complexity
37///
38/// O(iterations × E)
39pub fn label_propagation(store: &LpgStore, max_iterations: usize) -> FxHashMap<NodeId, u64> {
40    let nodes = store.node_ids();
41    let n = nodes.len();
42
43    if n == 0 {
44        return FxHashMap::default();
45    }
46
47    // Initialize labels: each node gets its own unique label
48    let mut labels: FxHashMap<NodeId, u64> = FxHashMap::default();
49    for (idx, &node) in nodes.iter().enumerate() {
50        labels.insert(node, idx as u64);
51    }
52
53    let max_iter = if max_iterations == 0 {
54        n * 10
55    } else {
56        max_iterations
57    };
58
59    for _ in 0..max_iter {
60        let mut changed = false;
61
62        // Update labels in random order (here we use insertion order)
63        for &node in &nodes {
64            // Get neighbor labels and their frequencies
65            let mut label_counts: FxHashMap<u64, usize> = FxHashMap::default();
66
67            // Consider both outgoing and incoming edges (undirected community detection)
68            for (neighbor, _) in store.edges_from(node, Direction::Outgoing) {
69                if let Some(&label) = labels.get(&neighbor) {
70                    *label_counts.entry(label).or_insert(0) += 1;
71                }
72            }
73
74            // For undirected behavior, also consider incoming edges
75            for &other_node in &nodes {
76                for (neighbor, _) in store.edges_from(other_node, Direction::Outgoing) {
77                    if neighbor == node {
78                        if let Some(&label) = labels.get(&other_node) {
79                            *label_counts.entry(label).or_insert(0) += 1;
80                        }
81                    }
82                }
83            }
84
85            if label_counts.is_empty() {
86                continue;
87            }
88
89            // Find the most frequent label
90            let max_count = *label_counts.values().max().unwrap_or(&0);
91            let max_labels: Vec<u64> = label_counts
92                .into_iter()
93                .filter(|&(_, count)| count == max_count)
94                .map(|(label, _)| label)
95                .collect();
96
97            // Choose the smallest label in case of tie (deterministic)
98            let new_label = *max_labels.iter().min().unwrap();
99            let current_label = *labels.get(&node).unwrap();
100
101            if new_label != current_label {
102                labels.insert(node, new_label);
103                changed = true;
104            }
105        }
106
107        if !changed {
108            break;
109        }
110    }
111
112    // Normalize labels to be contiguous starting from 0
113    let unique_labels: FxHashSet<u64> = labels.values().copied().collect();
114    let mut label_map: FxHashMap<u64, u64> = FxHashMap::default();
115    for (idx, label) in unique_labels.into_iter().enumerate() {
116        label_map.insert(label, idx as u64);
117    }
118
119    labels
120        .into_iter()
121        .map(|(node, label)| (node, *label_map.get(&label).unwrap()))
122        .collect()
123}
124
125// ============================================================================
126// Louvain Algorithm
127// ============================================================================
128
129/// Result of Louvain algorithm.
130#[derive(Debug, Clone)]
131pub struct LouvainResult {
132    /// Community assignment for each node.
133    pub communities: FxHashMap<NodeId, u64>,
134    /// Final modularity score.
135    pub modularity: f64,
136    /// Number of communities detected.
137    pub num_communities: usize,
138}
139
140/// Detects communities using the Louvain algorithm.
141///
142/// The Louvain algorithm optimizes modularity through a greedy approach,
143/// consisting of two phases that are repeated iteratively:
144/// 1. Local optimization: Move nodes to neighboring communities if it increases modularity
145/// 2. Aggregation: Build a new graph where communities become super-nodes
146///
147/// # Arguments
148///
149/// * `store` - The graph store
150/// * `resolution` - Resolution parameter (higher = smaller communities, default 1.0)
151///
152/// # Returns
153///
154/// Community assignments and modularity score.
155///
156/// # Complexity
157///
158/// O(V log V) on average for sparse graphs
159pub fn louvain(store: &LpgStore, resolution: f64) -> LouvainResult {
160    let nodes = store.node_ids();
161    let n = nodes.len();
162
163    if n == 0 {
164        return LouvainResult {
165            communities: FxHashMap::default(),
166            modularity: 0.0,
167            num_communities: 0,
168        };
169    }
170
171    // Build node index mapping
172    let mut node_to_idx: FxHashMap<NodeId, usize> = FxHashMap::default();
173    for (idx, &node) in nodes.iter().enumerate() {
174        node_to_idx.insert(node, idx);
175    }
176
177    // Build adjacency with weights (for undirected graph)
178    // weights[i][j] = weight of edge between nodes i and j
179    let mut weights: Vec<FxHashMap<usize, f64>> = vec![FxHashMap::default(); n];
180    let mut total_weight = 0.0;
181
182    for &node in &nodes {
183        let i = *node_to_idx.get(&node).unwrap();
184        for (neighbor, _edge_id) in store.edges_from(node, Direction::Outgoing) {
185            if let Some(&j) = node_to_idx.get(&neighbor) {
186                // For undirected: add weight to both directions
187                let w = 1.0; // Could extract from edge property
188                *weights[i].entry(j).or_insert(0.0) += w;
189                *weights[j].entry(i).or_insert(0.0) += w;
190                total_weight += w;
191            }
192        }
193    }
194
195    // Handle isolated nodes
196    if total_weight == 0.0 {
197        let communities: FxHashMap<NodeId, u64> = nodes
198            .iter()
199            .enumerate()
200            .map(|(idx, &node)| (node, idx as u64))
201            .collect();
202        return LouvainResult {
203            communities,
204            modularity: 0.0,
205            num_communities: n,
206        };
207    }
208
209    // Compute node degrees (sum of incident edge weights)
210    let degrees: Vec<f64> = (0..n).map(|i| weights[i].values().sum()).collect();
211
212    // Initialize: each node in its own community
213    let mut community: Vec<usize> = (0..n).collect();
214
215    // Community internal weights and total weights
216    let mut community_internal: FxHashMap<usize, f64> = FxHashMap::default();
217    let mut community_total: FxHashMap<usize, f64> = FxHashMap::default();
218
219    for i in 0..n {
220        community_total.insert(i, degrees[i]);
221        community_internal.insert(i, weights[i].get(&i).copied().unwrap_or(0.0));
222    }
223
224    // Phase 1: Local optimization
225    let mut improved = true;
226    while improved {
227        improved = false;
228
229        for i in 0..n {
230            let current_comm = community[i];
231
232            // Compute links to each neighboring community
233            let mut comm_links: FxHashMap<usize, f64> = FxHashMap::default();
234            for (&j, &w) in &weights[i] {
235                let c = community[j];
236                *comm_links.entry(c).or_insert(0.0) += w;
237            }
238
239            // Try moving to each neighboring community
240            let mut best_delta = 0.0;
241            let mut best_comm = current_comm;
242
243            // Remove node from current community for delta calculation
244            let ki = degrees[i];
245            let ki_in = comm_links.get(&current_comm).copied().unwrap_or(0.0);
246
247            for (&target_comm, &k_i_to_comm) in &comm_links {
248                if target_comm == current_comm {
249                    continue;
250                }
251
252                let sigma_tot = *community_total.get(&target_comm).unwrap_or(&0.0);
253
254                // Modularity delta for moving to target_comm
255                let delta = resolution
256                    * (k_i_to_comm
257                        - ki_in
258                        - ki * (sigma_tot - community_total.get(&current_comm).unwrap_or(&0.0)
259                            + ki)
260                            / (2.0 * total_weight));
261
262                if delta > best_delta {
263                    best_delta = delta;
264                    best_comm = target_comm;
265                }
266            }
267
268            if best_comm != current_comm {
269                // Move node to best community
270                // Update community statistics
271                *community_total.entry(current_comm).or_insert(0.0) -= ki;
272                *community_internal.entry(current_comm).or_insert(0.0) -=
273                    2.0 * ki_in + weights[i].get(&i).copied().unwrap_or(0.0);
274
275                community[i] = best_comm;
276
277                *community_total.entry(best_comm).or_insert(0.0) += ki;
278                let k_i_best = comm_links.get(&best_comm).copied().unwrap_or(0.0);
279                *community_internal.entry(best_comm).or_insert(0.0) +=
280                    2.0 * k_i_best + weights[i].get(&i).copied().unwrap_or(0.0);
281
282                improved = true;
283            }
284        }
285    }
286
287    // Normalize community IDs
288    let unique_comms: FxHashSet<usize> = community.iter().copied().collect();
289    let mut comm_map: FxHashMap<usize, u64> = FxHashMap::default();
290    for (idx, c) in unique_comms.iter().enumerate() {
291        comm_map.insert(*c, idx as u64);
292    }
293
294    let communities: FxHashMap<NodeId, u64> = nodes
295        .iter()
296        .enumerate()
297        .map(|(i, &node)| (node, *comm_map.get(&community[i]).unwrap()))
298        .collect();
299
300    // Compute final modularity
301    let modularity = compute_modularity(&weights, &community, total_weight, resolution);
302
303    LouvainResult {
304        communities,
305        modularity,
306        num_communities: unique_comms.len(),
307    }
308}
309
310/// Computes the modularity of a community assignment.
311fn compute_modularity(
312    weights: &[FxHashMap<usize, f64>],
313    community: &[usize],
314    total_weight: f64,
315    resolution: f64,
316) -> f64 {
317    let n = community.len();
318    let m2 = 2.0 * total_weight;
319
320    if m2 == 0.0 {
321        return 0.0;
322    }
323
324    let degrees: Vec<f64> = (0..n).map(|i| weights[i].values().sum()).collect();
325
326    let mut modularity = 0.0;
327
328    for i in 0..n {
329        for (&j, &a_ij) in &weights[i] {
330            if community[i] == community[j] {
331                modularity += a_ij - resolution * degrees[i] * degrees[j] / m2;
332            }
333        }
334    }
335
336    modularity / m2
337}
338
339/// Returns the number of communities detected.
340pub fn community_count(communities: &FxHashMap<NodeId, u64>) -> usize {
341    let unique: FxHashSet<u64> = communities.values().copied().collect();
342    unique.len()
343}
344
345// ============================================================================
346// Algorithm Wrappers for Plugin Registry
347// ============================================================================
348
349/// Static parameter definitions for Label Propagation algorithm.
350static LABEL_PROP_PARAMS: OnceLock<Vec<ParameterDef>> = OnceLock::new();
351
352fn label_prop_params() -> &'static [ParameterDef] {
353    LABEL_PROP_PARAMS.get_or_init(|| {
354        vec![ParameterDef {
355            name: "max_iterations".to_string(),
356            description: "Maximum iterations (0 for unlimited, default: 100)".to_string(),
357            param_type: ParameterType::Integer,
358            required: false,
359            default: Some("100".to_string()),
360        }]
361    })
362}
363
364/// Label Propagation algorithm wrapper.
365pub struct LabelPropagationAlgorithm;
366
367impl GraphAlgorithm for LabelPropagationAlgorithm {
368    fn name(&self) -> &str {
369        "label_propagation"
370    }
371
372    fn description(&self) -> &str {
373        "Label Propagation community detection"
374    }
375
376    fn parameters(&self) -> &[ParameterDef] {
377        label_prop_params()
378    }
379
380    fn execute(&self, store: &LpgStore, params: &Parameters) -> Result<AlgorithmResult> {
381        let max_iter = params.get_int("max_iterations").unwrap_or(100) as usize;
382
383        let communities = label_propagation(store, max_iter);
384
385        let mut builder = ComponentResultBuilder::with_capacity(communities.len());
386        for (node, community_id) in communities {
387            builder.push(node, community_id);
388        }
389
390        Ok(builder.build())
391    }
392}
393
394/// Static parameter definitions for Louvain algorithm.
395static LOUVAIN_PARAMS: OnceLock<Vec<ParameterDef>> = OnceLock::new();
396
397fn louvain_params() -> &'static [ParameterDef] {
398    LOUVAIN_PARAMS.get_or_init(|| {
399        vec![ParameterDef {
400            name: "resolution".to_string(),
401            description: "Resolution parameter (default: 1.0)".to_string(),
402            param_type: ParameterType::Float,
403            required: false,
404            default: Some("1.0".to_string()),
405        }]
406    })
407}
408
409/// Louvain algorithm wrapper.
410pub struct LouvainAlgorithm;
411
412impl GraphAlgorithm for LouvainAlgorithm {
413    fn name(&self) -> &str {
414        "louvain"
415    }
416
417    fn description(&self) -> &str {
418        "Louvain community detection (modularity optimization)"
419    }
420
421    fn parameters(&self) -> &[ParameterDef] {
422        louvain_params()
423    }
424
425    fn execute(&self, store: &LpgStore, params: &Parameters) -> Result<AlgorithmResult> {
426        let resolution = params.get_float("resolution").unwrap_or(1.0);
427
428        let result = louvain(store, resolution);
429
430        let mut output = AlgorithmResult::new(vec![
431            "node_id".to_string(),
432            "community_id".to_string(),
433            "modularity".to_string(),
434        ]);
435
436        for (node, community_id) in result.communities {
437            output.add_row(vec![
438                Value::Int64(node.0 as i64),
439                Value::Int64(community_id as i64),
440                Value::Float64(result.modularity),
441            ]);
442        }
443
444        Ok(output)
445    }
446}
447
448// ============================================================================
449// Tests
450// ============================================================================
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    fn create_two_cliques_graph() -> LpgStore {
457        // Two cliques connected by one edge
458        // Clique 1: 0-1-2-3 (fully connected)
459        // Clique 2: 4-5-6-7 (fully connected)
460        // Bridge: 3-4
461        let store = LpgStore::new();
462
463        let nodes: Vec<NodeId> = (0..8).map(|_| store.create_node(&["Node"])).collect();
464
465        // Clique 1
466        for i in 0..4 {
467            for j in (i + 1)..4 {
468                store.create_edge(nodes[i], nodes[j], "EDGE");
469                store.create_edge(nodes[j], nodes[i], "EDGE");
470            }
471        }
472
473        // Clique 2
474        for i in 4..8 {
475            for j in (i + 1)..8 {
476                store.create_edge(nodes[i], nodes[j], "EDGE");
477                store.create_edge(nodes[j], nodes[i], "EDGE");
478            }
479        }
480
481        // Bridge
482        store.create_edge(nodes[3], nodes[4], "EDGE");
483        store.create_edge(nodes[4], nodes[3], "EDGE");
484
485        store
486    }
487
488    fn create_simple_graph() -> LpgStore {
489        let store = LpgStore::new();
490
491        // Simple chain: 0 -> 1 -> 2
492        let n0 = store.create_node(&["Node"]);
493        let n1 = store.create_node(&["Node"]);
494        let n2 = store.create_node(&["Node"]);
495
496        store.create_edge(n0, n1, "EDGE");
497        store.create_edge(n1, n2, "EDGE");
498
499        store
500    }
501
502    #[test]
503    fn test_label_propagation_basic() {
504        let store = create_simple_graph();
505        let communities = label_propagation(&store, 100);
506
507        assert_eq!(communities.len(), 3);
508
509        // All nodes should have some community assignment
510        for (_, &comm) in &communities {
511            assert!(comm < 3);
512        }
513    }
514
515    #[test]
516    fn test_label_propagation_cliques() {
517        let store = create_two_cliques_graph();
518        let communities = label_propagation(&store, 100);
519
520        assert_eq!(communities.len(), 8);
521
522        // Should detect 2 communities (ideally)
523        let num_comms = community_count(&communities);
524        assert!(num_comms >= 1 && num_comms <= 8); // May vary due to algorithm randomness
525    }
526
527    #[test]
528    fn test_label_propagation_empty() {
529        let store = LpgStore::new();
530        let communities = label_propagation(&store, 100);
531        assert!(communities.is_empty());
532    }
533
534    #[test]
535    fn test_label_propagation_single_node() {
536        let store = LpgStore::new();
537        store.create_node(&["Node"]);
538
539        let communities = label_propagation(&store, 100);
540        assert_eq!(communities.len(), 1);
541    }
542
543    #[test]
544    fn test_louvain_basic() {
545        let store = create_simple_graph();
546        let result = louvain(&store, 1.0);
547
548        assert_eq!(result.communities.len(), 3);
549        assert!(result.num_communities >= 1);
550    }
551
552    #[test]
553    fn test_louvain_cliques() {
554        let store = create_two_cliques_graph();
555        let result = louvain(&store, 1.0);
556
557        assert_eq!(result.communities.len(), 8);
558
559        // Should detect approximately 2 communities
560        // Louvain should find good modularity
561        assert!(result.num_communities >= 1 && result.num_communities <= 8);
562    }
563
564    #[test]
565    fn test_louvain_empty() {
566        let store = LpgStore::new();
567        let result = louvain(&store, 1.0);
568
569        assert!(result.communities.is_empty());
570        assert_eq!(result.modularity, 0.0);
571        assert_eq!(result.num_communities, 0);
572    }
573
574    #[test]
575    fn test_louvain_isolated_nodes() {
576        let store = LpgStore::new();
577        store.create_node(&["Node"]);
578        store.create_node(&["Node"]);
579        store.create_node(&["Node"]);
580
581        let result = louvain(&store, 1.0);
582
583        // Each isolated node should be its own community
584        assert_eq!(result.communities.len(), 3);
585        assert_eq!(result.num_communities, 3);
586    }
587
588    #[test]
589    fn test_louvain_resolution_parameter() {
590        let store = create_two_cliques_graph();
591
592        // Low resolution: fewer, larger communities
593        let result_low = louvain(&store, 0.5);
594
595        // High resolution: more, smaller communities
596        let result_high = louvain(&store, 2.0);
597
598        // Both should be valid
599        assert!(!result_low.communities.is_empty());
600        assert!(!result_high.communities.is_empty());
601    }
602
603    #[test]
604    fn test_community_count() {
605        let mut communities: FxHashMap<NodeId, u64> = FxHashMap::default();
606        communities.insert(NodeId::new(0), 0);
607        communities.insert(NodeId::new(1), 0);
608        communities.insert(NodeId::new(2), 1);
609        communities.insert(NodeId::new(3), 1);
610        communities.insert(NodeId::new(4), 2);
611
612        assert_eq!(community_count(&communities), 3);
613    }
614}