rustkernel_graph/
community.rs

1//! Community detection kernels.
2//!
3//! This module provides algorithms for detecting communities in graphs:
4//! - Louvain algorithm (multi-level modularity optimization)
5//! - Modularity score calculation
6//! - Label propagation (fast approximate community detection)
7
8use crate::types::{CommunityResult, CsrGraph};
9use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
10use std::collections::HashMap;
11
12// ============================================================================
13// Modularity Score Kernel
14// ============================================================================
15
16/// Modularity score kernel.
17///
18/// Computes the modularity Q = (1/2m) * Σ[Aij - ki*kj/2m] * δ(ci, cj)
19/// where ki is degree of node i and ci is the community of node i.
20#[derive(Debug, Clone)]
21pub struct ModularityScore {
22    metadata: KernelMetadata,
23}
24
25impl ModularityScore {
26    /// Create a new modularity score kernel.
27    #[must_use]
28    pub fn new() -> Self {
29        Self {
30            metadata: KernelMetadata::batch("graph/modularity-score", Domain::GraphAnalytics)
31                .with_description("Modularity score Q calculation")
32                .with_throughput(100_000)
33                .with_latency_us(10.0),
34        }
35    }
36
37    /// Compute modularity score for a given community assignment.
38    ///
39    /// Q = (1/2m) * Σ[Aij - ki*kj/2m] * δ(ci, cj)
40    pub fn compute(graph: &CsrGraph, communities: &[u64]) -> f64 {
41        let n = graph.num_nodes;
42        if n == 0 || graph.num_edges == 0 {
43            return 0.0;
44        }
45
46        let m = graph.num_edges as f64;
47        let two_m = 2.0 * m;
48
49        // Precompute degrees
50        let degrees: Vec<f64> = (0..n).map(|i| graph.out_degree(i as u64) as f64).collect();
51
52        let mut q = 0.0;
53
54        // For each edge, add contribution if both endpoints are in same community
55        for i in 0..n {
56            let ci = communities[i];
57            let ki = degrees[i];
58
59            for &j in graph.neighbors(i as u64) {
60                let j = j as usize;
61                let cj = communities[j];
62
63                if ci == cj {
64                    let kj = degrees[j];
65                    // A_ij = 1 (edge exists), subtract expected edges
66                    q += 1.0 - (ki * kj) / two_m;
67                }
68            }
69        }
70
71        q / two_m
72    }
73
74    /// Compute modularity change when moving node to a new community.
75    pub fn delta_modularity(
76        graph: &CsrGraph,
77        node: usize,
78        old_community: u64,
79        new_community: u64,
80        communities: &[u64],
81        community_degrees: &HashMap<u64, f64>,
82        m: f64,
83    ) -> f64 {
84        if old_community == new_community {
85            return 0.0;
86        }
87
88        let ki = graph.out_degree(node as u64) as f64;
89        let two_m = 2.0 * m;
90
91        // Count edges to old and new communities
92        let mut edges_to_old = 0.0;
93        let mut edges_to_new = 0.0;
94
95        for &neighbor in graph.neighbors(node as u64) {
96            let nc = communities[neighbor as usize];
97            if nc == old_community {
98                edges_to_old += 1.0;
99            } else if nc == new_community {
100                edges_to_new += 1.0;
101            }
102        }
103
104        let sigma_old = community_degrees
105            .get(&old_community)
106            .copied()
107            .unwrap_or(0.0);
108        let sigma_new = community_degrees
109            .get(&new_community)
110            .copied()
111            .unwrap_or(0.0);
112
113        // Delta Q = [edges_to_new - sigma_new * ki / 2m] - [edges_to_old - (sigma_old - ki) * ki / 2m]
114        let delta = (edges_to_new - sigma_new * ki / two_m)
115            - (edges_to_old - (sigma_old - ki) * ki / two_m);
116
117        delta / m
118    }
119}
120
121impl Default for ModularityScore {
122    fn default() -> Self {
123        Self::new()
124    }
125}
126
127impl GpuKernel for ModularityScore {
128    fn metadata(&self) -> &KernelMetadata {
129        &self.metadata
130    }
131}
132
133// ============================================================================
134// Louvain Community Detection Kernel
135// ============================================================================
136
137/// Louvain community detection kernel.
138///
139/// Multi-level modularity optimization using greedy local moves.
140#[derive(Debug, Clone)]
141pub struct LouvainCommunity {
142    metadata: KernelMetadata,
143}
144
145impl LouvainCommunity {
146    /// Create a new Louvain community detection kernel.
147    #[must_use]
148    pub fn new() -> Self {
149        Self {
150            metadata: KernelMetadata::batch("graph/louvain-community", Domain::GraphAnalytics)
151                .with_description("Louvain community detection (modularity optimization)")
152                .with_throughput(10_000)
153                .with_latency_us(100.0),
154        }
155    }
156
157    /// Run Louvain algorithm for community detection.
158    ///
159    /// # Arguments
160    /// * `graph` - Input graph
161    /// * `max_iterations` - Maximum number of passes over all nodes
162    /// * `min_modularity_gain` - Stop if modularity improvement is below this threshold
163    pub fn compute(
164        graph: &CsrGraph,
165        max_iterations: u32,
166        min_modularity_gain: f64,
167    ) -> CommunityResult {
168        let n = graph.num_nodes;
169        if n == 0 {
170            return CommunityResult {
171                assignments: Vec::new(),
172                num_communities: 0,
173                modularity: 0.0,
174            };
175        }
176
177        // Initialize: each node in its own community
178        let mut communities: Vec<u64> = (0..n).map(|i| i as u64).collect();
179
180        // Precompute total edges
181        let m = graph.num_edges as f64;
182        if m == 0.0 {
183            return CommunityResult {
184                assignments: communities,
185                num_communities: n,
186                modularity: 0.0,
187            };
188        }
189
190        // Track community degrees (sum of degrees of nodes in community)
191        let mut community_degrees: HashMap<u64, f64> = HashMap::new();
192        for i in 0..n {
193            let degree = graph.out_degree(i as u64) as f64;
194            *community_degrees.entry(i as u64).or_insert(0.0) += degree;
195        }
196
197        // Track edges within each community
198        let mut community_internal_edges: HashMap<u64, f64> = HashMap::new();
199
200        let mut improved = true;
201        let mut iteration = 0;
202
203        while improved && iteration < max_iterations {
204            improved = false;
205            iteration += 1;
206            let mut total_gain = 0.0;
207
208            // Try to move each node to a better community
209            for node in 0..n {
210                let current_community = communities[node];
211                let node_degree = graph.out_degree(node as u64) as f64;
212
213                // Count edges to each neighboring community
214                let mut neighbor_communities: HashMap<u64, f64> = HashMap::new();
215                for &neighbor in graph.neighbors(node as u64) {
216                    let nc = communities[neighbor as usize];
217                    *neighbor_communities.entry(nc).or_insert(0.0) += 1.0;
218                }
219
220                // Find best community to move to
221                let mut best_community = current_community;
222                let mut best_gain = 0.0;
223
224                for (&comm, &edges_to_comm) in &neighbor_communities {
225                    if comm == current_community {
226                        continue;
227                    }
228
229                    let sigma_comm = community_degrees.get(&comm).copied().unwrap_or(0.0);
230                    let sigma_current = community_degrees
231                        .get(&current_community)
232                        .copied()
233                        .unwrap_or(0.0);
234                    let edges_to_current = neighbor_communities
235                        .get(&current_community)
236                        .copied()
237                        .unwrap_or(0.0);
238
239                    // Delta Q for moving node from current_community to comm
240                    let gain = (edges_to_comm - edges_to_current)
241                        - node_degree * (sigma_comm - sigma_current + node_degree) / (2.0 * m);
242
243                    if gain > best_gain {
244                        best_gain = gain;
245                        best_community = comm;
246                    }
247                }
248
249                // Move node if beneficial
250                if best_gain > min_modularity_gain {
251                    // Update community degrees
252                    if let Some(d) = community_degrees.get_mut(&current_community) {
253                        *d -= node_degree;
254                    }
255                    *community_degrees.entry(best_community).or_insert(0.0) += node_degree;
256
257                    // Update internal edges
258                    let edges_to_current = neighbor_communities
259                        .get(&current_community)
260                        .copied()
261                        .unwrap_or(0.0);
262                    if let Some(e) = community_internal_edges.get_mut(&current_community) {
263                        *e -= edges_to_current;
264                    }
265                    let edges_to_best = neighbor_communities
266                        .get(&best_community)
267                        .copied()
268                        .unwrap_or(0.0);
269                    *community_internal_edges
270                        .entry(best_community)
271                        .or_insert(0.0) += edges_to_best;
272
273                    communities[node] = best_community;
274                    improved = true;
275                    total_gain += best_gain;
276                }
277            }
278
279            // Early termination if gain is negligible
280            if total_gain < min_modularity_gain {
281                break;
282            }
283        }
284
285        // Renumber communities to be contiguous
286        let mut community_map: HashMap<u64, u64> = HashMap::new();
287        let mut next_id = 0u64;
288
289        for c in &mut communities {
290            let new_id = *community_map.entry(*c).or_insert_with(|| {
291                let id = next_id;
292                next_id += 1;
293                id
294            });
295            *c = new_id;
296        }
297
298        // Compute final modularity
299        let modularity = ModularityScore::compute(graph, &communities);
300
301        CommunityResult {
302            assignments: communities,
303            num_communities: next_id as usize,
304            modularity,
305        }
306    }
307}
308
309impl Default for LouvainCommunity {
310    fn default() -> Self {
311        Self::new()
312    }
313}
314
315impl GpuKernel for LouvainCommunity {
316    fn metadata(&self) -> &KernelMetadata {
317        &self.metadata
318    }
319}
320
321// ============================================================================
322// Label Propagation Kernel
323// ============================================================================
324
325/// Label propagation community detection kernel.
326///
327/// Fast approximate community detection using label propagation.
328/// Each node adopts the most frequent label among its neighbors.
329#[derive(Debug, Clone)]
330pub struct LabelPropagation {
331    metadata: KernelMetadata,
332}
333
334impl LabelPropagation {
335    /// Create a new label propagation kernel.
336    #[must_use]
337    pub fn new() -> Self {
338        Self {
339            metadata: KernelMetadata::batch("graph/label-propagation", Domain::GraphAnalytics)
340                .with_description("Label propagation community detection")
341                .with_throughput(100_000)
342                .with_latency_us(10.0),
343        }
344    }
345
346    /// Run label propagation algorithm.
347    ///
348    /// # Arguments
349    /// * `graph` - Input graph
350    /// * `max_iterations` - Maximum number of iterations
351    pub fn compute(graph: &CsrGraph, max_iterations: u32) -> CommunityResult {
352        let n = graph.num_nodes;
353        if n == 0 {
354            return CommunityResult {
355                assignments: Vec::new(),
356                num_communities: 0,
357                modularity: 0.0,
358            };
359        }
360
361        // Initialize: each node has its own label
362        let mut labels: Vec<u64> = (0..n).map(|i| i as u64).collect();
363
364        for _ in 0..max_iterations {
365            let mut changed = false;
366
367            // Update labels in random order (we use sequential for determinism)
368            for node in 0..n {
369                // Count labels of neighbors
370                let mut label_counts: HashMap<u64, usize> = HashMap::new();
371
372                for &neighbor in graph.neighbors(node as u64) {
373                    *label_counts.entry(labels[neighbor as usize]).or_insert(0) += 1;
374                }
375
376                // Find most frequent label
377                if let Some((&best_label, _)) = label_counts.iter().max_by_key(|(_, count)| *count)
378                {
379                    if labels[node] != best_label {
380                        labels[node] = best_label;
381                        changed = true;
382                    }
383                }
384            }
385
386            if !changed {
387                break;
388            }
389        }
390
391        // Renumber labels to be contiguous
392        let mut label_map: HashMap<u64, u64> = HashMap::new();
393        let mut next_id = 0u64;
394
395        for label in &mut labels {
396            let new_id = *label_map.entry(*label).or_insert_with(|| {
397                let id = next_id;
398                next_id += 1;
399                id
400            });
401            *label = new_id;
402        }
403
404        let modularity = ModularityScore::compute(graph, &labels);
405
406        CommunityResult {
407            assignments: labels,
408            num_communities: next_id as usize,
409            modularity,
410        }
411    }
412}
413
414impl Default for LabelPropagation {
415    fn default() -> Self {
416        Self::new()
417    }
418}
419
420impl GpuKernel for LabelPropagation {
421    fn metadata(&self) -> &KernelMetadata {
422        &self.metadata
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    fn create_two_cliques() -> CsrGraph {
431        // Two cliques connected by a single edge
432        // Clique 1: nodes 0, 1, 2 (fully connected)
433        // Clique 2: nodes 3, 4, 5 (fully connected)
434        // Bridge: 2 - 3
435        CsrGraph::from_edges(
436            6,
437            &[
438                // Clique 1
439                (0, 1),
440                (1, 0),
441                (0, 2),
442                (2, 0),
443                (1, 2),
444                (2, 1),
445                // Clique 2
446                (3, 4),
447                (4, 3),
448                (3, 5),
449                (5, 3),
450                (4, 5),
451                (5, 4),
452                // Bridge
453                (2, 3),
454                (3, 2),
455            ],
456        )
457    }
458
459    #[test]
460    fn test_modularity_score_metadata() {
461        let kernel = ModularityScore::new();
462        assert_eq!(kernel.metadata().id, "graph/modularity-score");
463        assert_eq!(kernel.metadata().domain, Domain::GraphAnalytics);
464    }
465
466    #[test]
467    fn test_modularity_perfect_partition() {
468        let graph = create_two_cliques();
469
470        // Perfect partition: nodes 0,1,2 in community 0, nodes 3,4,5 in community 1
471        let communities = vec![0, 0, 0, 1, 1, 1];
472        let q = ModularityScore::compute(&graph, &communities);
473
474        // Modularity should be positive for a good partition
475        assert!(q > 0.0, "Expected positive modularity, got {}", q);
476    }
477
478    #[test]
479    fn test_modularity_single_community() {
480        let graph = create_two_cliques();
481
482        // All nodes in same community
483        let communities = vec![0, 0, 0, 0, 0, 0];
484        let q_single = ModularityScore::compute(&graph, &communities);
485
486        // Perfect partition
487        let communities_perfect = vec![0, 0, 0, 1, 1, 1];
488        let q_perfect = ModularityScore::compute(&graph, &communities_perfect);
489
490        // Both should produce valid modularity values
491        // (The exact comparison depends on algorithm interpretation)
492        assert!(
493            q_single.is_finite(),
494            "Single community modularity should be finite"
495        );
496        assert!(
497            q_perfect.is_finite(),
498            "Perfect partition modularity should be finite"
499        );
500        assert!(
501            q_perfect > 0.0,
502            "Perfect partition should have positive modularity"
503        );
504    }
505
506    #[test]
507    fn test_louvain_finds_communities() {
508        let graph = create_two_cliques();
509        let result = LouvainCommunity::compute(&graph, 100, 1e-6);
510
511        // Should find 2 communities
512        assert_eq!(
513            result.num_communities, 2,
514            "Expected 2 communities, got {}",
515            result.num_communities
516        );
517
518        // Nodes in same clique should be in same community
519        assert_eq!(result.assignments[0], result.assignments[1]);
520        assert_eq!(result.assignments[1], result.assignments[2]);
521        assert_eq!(result.assignments[3], result.assignments[4]);
522        assert_eq!(result.assignments[4], result.assignments[5]);
523
524        // Nodes in different cliques should be in different communities
525        assert_ne!(result.assignments[0], result.assignments[3]);
526
527        // Modularity should be positive
528        assert!(result.modularity > 0.0);
529    }
530
531    #[test]
532    fn test_label_propagation_finds_communities() {
533        let graph = create_two_cliques();
534        let result = LabelPropagation::compute(&graph, 100);
535
536        // Label propagation should find communities
537        // Note: Label propagation is a heuristic and may merge communities
538        // when there are bridge edges between them. The key property we test
539        // is that nodes within a clique stay together.
540        assert!(
541            result.num_communities >= 1 && result.num_communities <= 2,
542            "Expected 1-2 communities, got {}",
543            result.num_communities
544        );
545
546        // Nodes within each clique should be in same community
547        assert_eq!(result.assignments[0], result.assignments[1]);
548        assert_eq!(result.assignments[1], result.assignments[2]);
549        assert_eq!(result.assignments[3], result.assignments[4]);
550        assert_eq!(result.assignments[4], result.assignments[5]);
551
552        // Modularity should be non-negative
553        assert!(result.modularity >= 0.0);
554    }
555}