Skip to main content

oxirs_graphrag/graph/
community.rs

1//! Community detection for hierarchical summarization
2
3use crate::{CommunitySummary, GraphRAGError, GraphRAGResult, Triple};
4use petgraph::graph::{NodeIndex, UnGraph};
5use scirs2_core::random::{seeded_rng, Random};
6use std::collections::{HashMap, HashSet};
7
8/// Community detection algorithm
9#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
10pub enum CommunityAlgorithm {
11    /// Louvain algorithm (baseline: ~0.65 modularity)
12    Louvain,
13    /// Leiden algorithm (target: >0.75 modularity, improved Louvain)
14    #[default]
15    Leiden,
16    /// Label propagation
17    LabelPropagation,
18    /// Connected components
19    ConnectedComponents,
20    /// Hierarchical (multi-level)
21    Hierarchical,
22}
23
24/// Community detector configuration
25#[derive(Debug, Clone)]
26pub struct CommunityConfig {
27    /// Algorithm to use
28    pub algorithm: CommunityAlgorithm,
29    /// Resolution parameter for Louvain/Leiden
30    pub resolution: f64,
31    /// Minimum community size
32    pub min_community_size: usize,
33    /// Maximum number of communities
34    pub max_communities: usize,
35    /// Number of iterations for iterative algorithms
36    pub max_iterations: usize,
37    /// Random seed for reproducibility
38    pub random_seed: u64,
39}
40
41impl Default for CommunityConfig {
42    fn default() -> Self {
43        Self {
44            algorithm: CommunityAlgorithm::Leiden,
45            resolution: 1.0,
46            min_community_size: 3,
47            max_communities: 50,
48            max_iterations: 10,
49            random_seed: 42,
50        }
51    }
52}
53
54/// Community detector
55pub struct CommunityDetector {
56    config: CommunityConfig,
57}
58
59impl Default for CommunityDetector {
60    fn default() -> Self {
61        Self::new(CommunityConfig::default())
62    }
63}
64
65impl CommunityDetector {
66    pub fn new(config: CommunityConfig) -> Self {
67        Self { config }
68    }
69
70    /// Detect communities in the given subgraph
71    pub fn detect(&self, triples: &[Triple]) -> GraphRAGResult<Vec<CommunitySummary>> {
72        if triples.is_empty() {
73            return Ok(vec![]);
74        }
75
76        // Build graph
77        let (graph, node_map) = self.build_graph(triples);
78
79        // Detect communities based on algorithm
80        let communities = match self.config.algorithm {
81            CommunityAlgorithm::Louvain => self.louvain(&graph, &node_map)?,
82            CommunityAlgorithm::Leiden => self.leiden(&graph, &node_map)?,
83            CommunityAlgorithm::LabelPropagation => self.label_propagation(&graph, &node_map),
84            CommunityAlgorithm::ConnectedComponents => self.connected_components(&graph, &node_map),
85            CommunityAlgorithm::Hierarchical => {
86                return self.detect_hierarchical(triples);
87            }
88        };
89
90        // Filter and create summaries
91        let summaries = self.create_summaries(communities, triples);
92
93        Ok(summaries)
94    }
95
96    /// Build undirected graph from triples
97    fn build_graph(&self, triples: &[Triple]) -> (UnGraph<String, ()>, HashMap<String, NodeIndex>) {
98        let mut graph: UnGraph<String, ()> = UnGraph::new_undirected();
99        let mut node_map: HashMap<String, NodeIndex> = HashMap::new();
100
101        for triple in triples {
102            let subj_idx = *node_map
103                .entry(triple.subject.clone())
104                .or_insert_with(|| graph.add_node(triple.subject.clone()));
105            let obj_idx = *node_map
106                .entry(triple.object.clone())
107                .or_insert_with(|| graph.add_node(triple.object.clone()));
108
109            if subj_idx != obj_idx && graph.find_edge(subj_idx, obj_idx).is_none() {
110                graph.add_edge(subj_idx, obj_idx, ());
111            }
112        }
113
114        (graph, node_map)
115    }
116
117    /// Simplified Louvain algorithm
118    fn louvain(
119        &self,
120        graph: &UnGraph<String, ()>,
121        node_map: &HashMap<String, NodeIndex>,
122    ) -> GraphRAGResult<Vec<HashSet<String>>> {
123        let node_count = graph.node_count();
124        if node_count == 0 {
125            return Ok(vec![]);
126        }
127
128        // Initialize: each node in its own community
129        let mut community: HashMap<NodeIndex, usize> = HashMap::new();
130        for (community_id, &idx) in node_map.values().enumerate() {
131            community.insert(idx, community_id);
132        }
133
134        // Total edges (for modularity calculation)
135        let m = graph.edge_count() as f64;
136        if m == 0.0 {
137            // No edges, each node is its own community
138            return Ok(node_map
139                .keys()
140                .map(|k| {
141                    let mut set = HashSet::new();
142                    set.insert(k.clone());
143                    set
144                })
145                .collect());
146        }
147
148        // Degree of each node
149        let degree: HashMap<NodeIndex, f64> = node_map
150            .values()
151            .map(|&idx| (idx, graph.neighbors(idx).count() as f64))
152            .collect();
153
154        // Build a deterministic node processing order. Rust's default
155        // HashMap/HashSet hasher is reseeded from OS entropy every process
156        // start, so `node_map.values()` iteration order is not reproducible
157        // across runs even with a fixed `random_seed`. Sort the base vector
158        // first (NodeIndex implements Ord), then Fisher-Yates shuffle it
159        // using the seeded RNG so the resulting order is a deterministic
160        // function of `random_seed` alone.
161        let mut node_order: Vec<NodeIndex> = node_map.values().copied().collect();
162        node_order.sort();
163        let mut rng = seeded_rng(self.config.random_seed);
164        for i in (1..node_order.len()).rev() {
165            let j = (rng.random_range(0.0..1.0) * (i + 1) as f64) as usize;
166            node_order.swap(i, j);
167        }
168
169        // Iterate
170        for _ in 0..self.config.max_iterations {
171            let mut changed = false;
172
173            for &node in &node_order {
174                let current_comm = match community.get(&node) {
175                    Some(&c) => c,
176                    None => continue,
177                };
178                let node_degree = degree.get(&node).copied().unwrap_or(0.0);
179
180                // Calculate modularity gain for each neighbor's community
181                let mut best_comm = current_comm;
182                let mut best_gain = 0.0;
183
184                // Sorted (not raw HashSet iteration) so ties always break
185                // the same way regardless of hasher seed.
186                let mut neighbor_comms: Vec<usize> = graph
187                    .neighbors(node)
188                    .filter_map(|n| community.get(&n).copied())
189                    .collect::<HashSet<usize>>()
190                    .into_iter()
191                    .collect();
192                neighbor_comms.sort_unstable();
193
194                for &neighbor_comm in &neighbor_comms {
195                    if neighbor_comm == current_comm {
196                        continue;
197                    }
198
199                    // Simplified modularity gain calculation
200                    let edges_to_comm: f64 = graph
201                        .neighbors(node)
202                        .filter(|n| community.get(n) == Some(&neighbor_comm))
203                        .count() as f64;
204
205                    let comm_degree: f64 = community
206                        .iter()
207                        .filter(|(_, &c)| c == neighbor_comm)
208                        .map(|(n, _)| degree.get(n).copied().unwrap_or(0.0))
209                        .sum();
210
211                    let gain = edges_to_comm / m
212                        - self.config.resolution * node_degree * comm_degree / (2.0 * m * m);
213
214                    if gain > best_gain {
215                        best_gain = gain;
216                        best_comm = neighbor_comm;
217                    }
218                }
219
220                if best_comm != current_comm && best_gain > 0.0 {
221                    community.insert(node, best_comm);
222                    changed = true;
223                }
224            }
225
226            if !changed {
227                break;
228            }
229        }
230
231        // Never settle for a partition worse than the trivial single-community
232        // partition: greedy hill-climbing can get stuck in a local optimum
233        // with negative modularity, which is strictly worse than "no
234        // community structure at all". The trivial partition is always a
235        // valid candidate — per `calculate_modularity`'s doc comment it has
236        // Q = 1 - resolution = 0 at the default resolution for any graph —
237        // so compare against it via the real formula (rather than
238        // hardcoding that constant) and fall back if the greedy result did
239        // worse.
240        let trivial: HashMap<NodeIndex, usize> =
241            node_map.values().map(|&idx| (idx, 0usize)).collect();
242        let greedy_modularity = self.calculate_modularity(graph, &community, m, &degree)?;
243        let trivial_modularity = self.calculate_modularity(graph, &trivial, m, &degree)?;
244
245        if trivial_modularity >= greedy_modularity {
246            return Ok(self.group_by_community(graph, &trivial));
247        }
248
249        // Group nodes by community
250        Ok(self.group_by_community(graph, &community))
251    }
252
253    /// Label propagation algorithm
254    fn label_propagation(
255        &self,
256        graph: &UnGraph<String, ()>,
257        node_map: &HashMap<String, NodeIndex>,
258    ) -> Vec<HashSet<String>> {
259        if graph.node_count() == 0 {
260            return vec![];
261        }
262
263        // Initialize labels
264        let mut labels: HashMap<NodeIndex, usize> = HashMap::new();
265        for (i, &idx) in node_map.values().enumerate() {
266            labels.insert(idx, i);
267        }
268
269        // Iterate
270        for _ in 0..self.config.max_iterations {
271            let mut changed = false;
272
273            for &node in node_map.values() {
274                // Count neighbor labels
275                let mut label_counts: HashMap<usize, usize> = HashMap::new();
276                for neighbor in graph.neighbors(node) {
277                    if let Some(&label) = labels.get(&neighbor) {
278                        *label_counts.entry(label).or_insert(0) += 1;
279                    }
280                }
281
282                // Assign most common label
283                if let Some((&best_label, _)) = label_counts.iter().max_by_key(|(_, &count)| count)
284                {
285                    if labels.get(&node) != Some(&best_label) {
286                        labels.insert(node, best_label);
287                        changed = true;
288                    }
289                }
290            }
291
292            if !changed {
293                break;
294            }
295        }
296
297        self.group_by_community(graph, &labels)
298    }
299
300    /// Connected components
301    fn connected_components(
302        &self,
303        graph: &UnGraph<String, ()>,
304        _node_map: &HashMap<String, NodeIndex>,
305    ) -> Vec<HashSet<String>> {
306        let sccs = petgraph::algo::kosaraju_scc(graph);
307
308        sccs.into_iter()
309            .map(|component| {
310                component
311                    .into_iter()
312                    .filter_map(|idx| graph.node_weight(idx).cloned())
313                    .collect()
314            })
315            .collect()
316    }
317
318    /// Leiden algorithm (improved Louvain with refinement phase)
319    fn leiden(
320        &self,
321        graph: &UnGraph<String, ()>,
322        node_map: &HashMap<String, NodeIndex>,
323    ) -> GraphRAGResult<Vec<HashSet<String>>> {
324        let node_count = graph.node_count();
325        if node_count == 0 {
326            return Ok(vec![]);
327        }
328
329        // Initialize: each node in its own community
330        let mut community: HashMap<NodeIndex, usize> = HashMap::new();
331        for (community_id, &idx) in node_map.values().enumerate() {
332            community.insert(idx, community_id);
333        }
334
335        let m = graph.edge_count() as f64;
336        if m == 0.0 {
337            return Ok(node_map
338                .keys()
339                .map(|k| {
340                    let mut set = HashSet::new();
341                    set.insert(k.clone());
342                    set
343                })
344                .collect());
345        }
346
347        let degree: HashMap<NodeIndex, f64> = node_map
348            .values()
349            .map(|&idx| (idx, graph.neighbors(idx).count() as f64))
350            .collect();
351
352        let mut rng = seeded_rng(self.config.random_seed);
353        let mut best_modularity = self.calculate_modularity(graph, &community, m, &degree)?;
354
355        // Main Leiden loop
356        for iteration in 0..self.config.max_iterations {
357            let mut changed = false;
358
359            // Phase 1: Local moving (like Louvain)
360            let mut node_order: Vec<NodeIndex> = node_map.values().copied().collect();
361            // Sort for a deterministic base order first — HashMap iteration
362            // order is randomized per-process, so shuffling it (even with a
363            // fixed seed) would only permute an already-random sequence and
364            // the final order would still not be reproducible.
365            node_order.sort();
366            // Shuffle for randomness
367            for i in (1..node_order.len()).rev() {
368                let j = (rng.random_range(0.0..1.0) * (i + 1) as f64) as usize;
369                node_order.swap(i, j);
370            }
371
372            for &node in &node_order {
373                let current_comm = match community.get(&node) {
374                    Some(&c) => c,
375                    None => continue,
376                };
377                let node_degree = degree.get(&node).copied().unwrap_or(0.0);
378
379                let mut best_comm = current_comm;
380                let mut best_gain = 0.0;
381
382                // Get neighbor communities (sorted so ties always break the
383                // same way regardless of HashSet iteration/hasher seed).
384                let mut neighbor_comms: Vec<usize> = graph
385                    .neighbors(node)
386                    .filter_map(|n| community.get(&n).copied())
387                    .collect::<HashSet<usize>>()
388                    .into_iter()
389                    .collect();
390                neighbor_comms.sort_unstable();
391
392                for &neighbor_comm in &neighbor_comms {
393                    if neighbor_comm == current_comm {
394                        continue;
395                    }
396
397                    let edges_to_comm: f64 = graph
398                        .neighbors(node)
399                        .filter(|n| community.get(n) == Some(&neighbor_comm))
400                        .count() as f64;
401
402                    let comm_degree: f64 = community
403                        .iter()
404                        .filter(|(_, &c)| c == neighbor_comm)
405                        .map(|(n, _)| degree.get(n).copied().unwrap_or(0.0))
406                        .sum();
407
408                    let gain = edges_to_comm / m
409                        - self.config.resolution * node_degree * comm_degree / (2.0 * m * m);
410
411                    if gain > best_gain {
412                        best_gain = gain;
413                        best_comm = neighbor_comm;
414                    }
415                }
416
417                if best_comm != current_comm && best_gain > 0.0 {
418                    community.insert(node, best_comm);
419                    changed = true;
420                }
421            }
422
423            // Phase 2: Refinement (what makes Leiden better than Louvain)
424            // Split communities and re-merge if it improves modularity.
425            // `refine_community` mutates `community` in place, so the order
426            // in which communities are visited can affect the outcome —
427            // sort instead of iterating the HashSet directly so this is a
428            // deterministic function of `random_seed`.
429            let mut unique_comms: Vec<usize> = community
430                .values()
431                .copied()
432                .collect::<HashSet<usize>>()
433                .into_iter()
434                .collect();
435            unique_comms.sort_unstable();
436            for &comm_id in &unique_comms {
437                let mut comm_nodes: Vec<NodeIndex> = community
438                    .iter()
439                    .filter(|(_, &c)| c == comm_id)
440                    .map(|(&n, _)| n)
441                    .collect();
442                comm_nodes.sort();
443
444                if comm_nodes.len() <= 1 {
445                    continue;
446                }
447
448                // Try to split and refine
449                self.refine_community(graph, &mut community, &comm_nodes, comm_id, m, &degree)?;
450            }
451
452            // Check modularity improvement
453            let current_modularity = self.calculate_modularity(graph, &community, m, &degree)?;
454            if current_modularity > best_modularity {
455                best_modularity = current_modularity;
456            } else if !changed {
457                break;
458            }
459
460            // Early stop if modularity is very high
461            if best_modularity > 0.95 || iteration > 0 && !changed {
462                break;
463            }
464        }
465
466        // Verify target: modularity > 0.75
467        if best_modularity < 0.75 {
468            tracing::warn!("Leiden modularity {:.3} below target 0.75", best_modularity);
469        } else {
470            tracing::info!("Leiden achieved modularity: {:.3}", best_modularity);
471        }
472
473        // Never settle for a partition worse than the trivial single-community
474        // partition (see `louvain`'s and `calculate_modularity`'s doc
475        // comments for why the trivial partition is always a valid,
476        // zero-modularity-at-default-resolution candidate). `community` here
477        // is the actual final assignment (post Phase 1 + Phase 2 of the last
478        // iteration), so recompute its modularity fresh rather than reusing
479        // `best_modularity`, which may reference an earlier, discarded state.
480        let trivial: HashMap<NodeIndex, usize> =
481            node_map.values().map(|&idx| (idx, 0usize)).collect();
482        let greedy_modularity = self.calculate_modularity(graph, &community, m, &degree)?;
483        let trivial_modularity = self.calculate_modularity(graph, &trivial, m, &degree)?;
484
485        if trivial_modularity >= greedy_modularity {
486            return Ok(self.group_by_community(graph, &trivial));
487        }
488
489        Ok(self.group_by_community(graph, &community))
490    }
491
492    /// Refine a community by attempting local splits
493    fn refine_community(
494        &self,
495        graph: &UnGraph<String, ()>,
496        community: &mut HashMap<NodeIndex, usize>,
497        comm_nodes: &[NodeIndex],
498        comm_id: usize,
499        m: f64,
500        degree: &HashMap<NodeIndex, f64>,
501    ) -> GraphRAGResult<()> {
502        if comm_nodes.len() < 2 {
503            return Ok(());
504        }
505
506        // Try to find a better split using local connectivity
507        let mut subcomm: HashMap<NodeIndex, usize> = HashMap::new();
508        for (i, &node) in comm_nodes.iter().enumerate() {
509            subcomm.insert(node, i);
510        }
511
512        // One pass of local moving within the community
513        let mut changed = false;
514        for &node in comm_nodes {
515            let current_sub = match subcomm.get(&node) {
516                Some(&c) => c,
517                None => continue,
518            };
519
520            // Count edges to each subcommunity
521            let mut sub_edges: HashMap<usize, f64> = HashMap::new();
522            for neighbor in graph.neighbors(node) {
523                if let Some(&sub) = subcomm.get(&neighbor) {
524                    *sub_edges.entry(sub).or_insert(0.0) += 1.0;
525                }
526            }
527
528            // Find best subcommunity. `max_by` returns the *last* maximal
529            // element on ties, and HashMap iteration order is randomized
530            // per-process, so sort by subcommunity id first — this makes
531            // ties break the same way on every run for a given seed.
532            let mut sorted_sub_edges: Vec<(&usize, &f64)> = sub_edges.iter().collect();
533            sorted_sub_edges.sort_unstable_by_key(|(sub, _)| **sub);
534            if let Some((&best_sub, _)) = sorted_sub_edges
535                .into_iter()
536                .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
537            {
538                if best_sub != current_sub {
539                    subcomm.insert(node, best_sub);
540                    changed = true;
541                }
542            }
543        }
544
545        // If we found a better partition, create new communities
546        if changed {
547            // Sort so relabeling (via `.enumerate()` below) is a
548            // deterministic function of `random_seed` instead of depending
549            // on HashSet iteration/hasher seed order.
550            let mut unique_subs: Vec<usize> = subcomm
551                .values()
552                .copied()
553                .collect::<HashSet<usize>>()
554                .into_iter()
555                .collect();
556            unique_subs.sort_unstable();
557            if unique_subs.len() > 1 {
558                let max_comm = community.values().max().copied().unwrap_or(0);
559                for (i, sub_id) in unique_subs.iter().enumerate() {
560                    for &node in comm_nodes {
561                        if subcomm.get(&node) == Some(sub_id) {
562                            let new_comm = if i == 0 { comm_id } else { max_comm + i };
563                            community.insert(node, new_comm);
564                        }
565                    }
566                }
567            }
568        }
569
570        Ok(())
571    }
572
573    /// Calculate the modularity of a community assignment.
574    ///
575    /// Standard Newman-Girvan modularity:
576    ///
577    /// ```text
578    /// Q = (1/2m) * Σ_{i,j} [A_ij - resolution·k_i·k_j/2m] · δ(c_i, c_j)
579    /// ```
580    ///
581    /// Computed here in its algebraically equivalent per-community form for
582    /// O(edges + nodes) efficiency instead of the O(n²) double sum:
583    ///
584    /// ```text
585    /// Q = Σ_c [ e_c/m - resolution · (d_c / 2m)² ]
586    /// ```
587    ///
588    /// where `e_c` is the number of intra-community edges and `d_c` is the
589    /// sum of degrees of nodes in community `c` (see
590    /// `community_detector::CommunityDetector::compute_modularity` for the
591    /// O(n²) form these two agree with). Sanity check: a single community
592    /// spanning the whole graph must give exactly `Q = 0` (no structure
593    /// beyond the null model) — `e_c = m`, `d_c = 2m` ⇒ `1 - resolution`,
594    /// which is `0` at the default `resolution = 1.0`.
595    fn calculate_modularity(
596        &self,
597        graph: &UnGraph<String, ()>,
598        community: &HashMap<NodeIndex, usize>,
599        m: f64,
600        degree: &HashMap<NodeIndex, f64>,
601    ) -> GraphRAGResult<f64> {
602        if m == 0.0 {
603            return Ok(0.0);
604        }
605
606        // e_c: intra-community edge counts.
607        let mut intra_edges: HashMap<usize, f64> = HashMap::new();
608        for edge in graph.edge_indices() {
609            if let Some((a, b)) = graph.edge_endpoints(edge) {
610                if let (Some(&ca), Some(&cb)) = (community.get(&a), community.get(&b)) {
611                    if ca == cb {
612                        *intra_edges.entry(ca).or_insert(0.0) += 1.0;
613                    }
614                }
615            }
616        }
617
618        // d_c: sum of node degrees per community.
619        let mut comm_degree_sum: HashMap<usize, f64> = HashMap::new();
620        for (&node, &comm) in community {
621            let deg = degree.get(&node).copied().unwrap_or(0.0);
622            *comm_degree_sum.entry(comm).or_insert(0.0) += deg;
623        }
624
625        let two_m = 2.0 * m;
626        let q: f64 = comm_degree_sum
627            .keys()
628            .map(|comm_id| {
629                let e_c = intra_edges.get(comm_id).copied().unwrap_or(0.0);
630                let d_c = comm_degree_sum.get(comm_id).copied().unwrap_or(0.0);
631                e_c / m - self.config.resolution * (d_c / two_m).powi(2)
632            })
633            .sum();
634
635        Ok(q)
636    }
637
638    /// Hierarchical community detection (multi-level)
639    fn detect_hierarchical(&self, triples: &[Triple]) -> GraphRAGResult<Vec<CommunitySummary>> {
640        let mut all_summaries = Vec::new();
641        let mut current_triples = triples.to_vec();
642        let mut level = 0;
643
644        while level < 5 && !current_triples.is_empty() {
645            let (graph, node_map) = self.build_graph(&current_triples);
646
647            if graph.node_count() < 10 {
648                break;
649            }
650
651            // Detect communities at this level using Leiden
652            let communities = self.leiden(&graph, &node_map)?;
653
654            // Create summaries for this level
655            let mut level_summaries = self.create_summaries(communities.clone(), &current_triples);
656
657            // Tag with level
658            for summary in &mut level_summaries {
659                summary.level = level;
660            }
661
662            all_summaries.extend(level_summaries);
663
664            // Coarsen graph: each community becomes a supernode
665            current_triples = self.coarsen_graph(&graph, &node_map, &communities)?;
666            level += 1;
667        }
668
669        Ok(all_summaries)
670    }
671
672    /// Coarsen graph by collapsing communities into supernodes
673    fn coarsen_graph(
674        &self,
675        graph: &UnGraph<String, ()>,
676        node_map: &HashMap<String, NodeIndex>,
677        communities: &[HashSet<String>],
678    ) -> GraphRAGResult<Vec<Triple>> {
679        let mut node_to_community: HashMap<String, usize> = HashMap::new();
680        for (comm_id, community) in communities.iter().enumerate() {
681            for node in community {
682                node_to_community.insert(node.clone(), comm_id);
683            }
684        }
685
686        let mut coarsened_triples = Vec::new();
687        let mut seen_edges: HashSet<(usize, usize)> = HashSet::new();
688
689        for edge in graph.edge_indices() {
690            if let Some((a, b)) = graph.edge_endpoints(edge) {
691                let label_a = graph.node_weight(a);
692                let label_b = graph.node_weight(b);
693
694                if let (Some(la), Some(lb)) = (label_a, label_b) {
695                    if let (Some(&comm_a), Some(&comm_b)) =
696                        (node_to_community.get(la), node_to_community.get(lb))
697                    {
698                        if comm_a != comm_b {
699                            let edge_key = if comm_a < comm_b {
700                                (comm_a, comm_b)
701                            } else {
702                                (comm_b, comm_a)
703                            };
704
705                            if !seen_edges.contains(&edge_key) {
706                                seen_edges.insert(edge_key);
707                                coarsened_triples.push(Triple::new(
708                                    format!("community_{}", comm_a),
709                                    "inter_community_link",
710                                    format!("community_{}", comm_b),
711                                ));
712                            }
713                        }
714                    }
715                }
716            }
717        }
718
719        Ok(coarsened_triples)
720    }
721
722    /// Group nodes by community assignment
723    fn group_by_community(
724        &self,
725        graph: &UnGraph<String, ()>,
726        assignment: &HashMap<NodeIndex, usize>,
727    ) -> Vec<HashSet<String>> {
728        let mut communities: HashMap<usize, HashSet<String>> = HashMap::new();
729
730        for (&node, &comm) in assignment {
731            if let Some(label) = graph.node_weight(node) {
732                communities.entry(comm).or_default().insert(label.clone());
733            }
734        }
735
736        communities.into_values().collect()
737    }
738
739    /// Create community summaries
740    fn create_summaries(
741        &self,
742        communities: Vec<HashSet<String>>,
743        triples: &[Triple],
744    ) -> Vec<CommunitySummary> {
745        // Build graph for proper modularity calculation
746        let (graph, node_map) = self.build_graph(triples);
747        let m = graph.edge_count() as f64;
748
749        // Create community assignments
750        let mut community_map: HashMap<NodeIndex, usize> = HashMap::new();
751        for (idx, entities) in communities.iter().enumerate() {
752            for entity in entities {
753                if let Some(&node_idx) = node_map.get(entity) {
754                    community_map.insert(node_idx, idx);
755                }
756            }
757        }
758
759        // Calculate degrees
760        let degree: HashMap<NodeIndex, f64> = node_map
761            .values()
762            .map(|&idx| (idx, graph.neighbors(idx).count() as f64))
763            .collect();
764
765        // Calculate overall partition modularity (Newman-Girvan formula).
766        // Delegate to `calculate_modularity` so there is a single
767        // implementation of the formula (previously this duplicated — and
768        // diverged from — the computation above, with its own normalization
769        // bug on top).
770        let overall_modularity = self
771            .calculate_modularity(&graph, &community_map, m, &degree)
772            .unwrap_or(0.0);
773
774        communities
775            .into_iter()
776            .enumerate()
777            .filter(|(_, entities)| entities.len() >= self.config.min_community_size)
778            .take(self.config.max_communities)
779            .map(|(idx, entities)| {
780                // Find representative triples
781                let representative_triples: Vec<Triple> = triples
782                    .iter()
783                    .filter(|t| entities.contains(&t.subject) || entities.contains(&t.object))
784                    .take(5)
785                    .cloned()
786                    .collect();
787
788                // Generate summary
789                let entity_list: Vec<String> = entities.iter().cloned().collect();
790                let summary = self.generate_summary(&entity_list, &representative_triples);
791
792                // All communities share the overall partition modularity
793                CommunitySummary {
794                    id: format!("community_{}", idx),
795                    summary,
796                    entities: entity_list,
797                    representative_triples,
798                    level: 0,
799                    modularity: overall_modularity,
800                }
801            })
802            .collect()
803    }
804
805    /// Generate a text summary for a community
806    fn generate_summary(&self, entities: &[String], triples: &[Triple]) -> String {
807        // Extract short names from URIs
808        let short_names: Vec<String> = entities
809            .iter()
810            .take(3)
811            .map(|uri| {
812                uri.rsplit('/')
813                    .next()
814                    .or_else(|| uri.rsplit('#').next())
815                    .unwrap_or(uri)
816                    .to_string()
817            })
818            .collect();
819
820        // Extract predicates
821        let predicates: HashSet<String> = triples
822            .iter()
823            .map(|t| {
824                t.predicate
825                    .rsplit('/')
826                    .next()
827                    .or_else(|| t.predicate.rsplit('#').next())
828                    .unwrap_or(&t.predicate)
829                    .to_string()
830            })
831            .collect();
832
833        let pred_str: Vec<String> = predicates.into_iter().take(3).collect();
834
835        format!(
836            "Community of {} entities including {} connected by {}",
837            entities.len(),
838            short_names.join(", "),
839            pred_str.join(", ")
840        )
841    }
842}
843
844#[cfg(test)]
845mod tests {
846    use super::*;
847
848    #[test]
849    fn test_community_detection() {
850        // Use min_community_size: 1 to ensure small communities are detected
851        let detector = CommunityDetector::new(CommunityConfig {
852            min_community_size: 1,
853            ..Default::default()
854        });
855
856        let triples = vec![
857            Triple::new("http://a", "http://rel", "http://b"),
858            Triple::new("http://b", "http://rel", "http://c"),
859            Triple::new("http://a", "http://rel", "http://c"),
860            Triple::new("http://x", "http://rel", "http://y"),
861            Triple::new("http://y", "http://rel", "http://z"),
862            Triple::new("http://x", "http://rel", "http://z"),
863        ];
864
865        let communities = detector.detect(&triples).expect("should succeed");
866
867        // Should detect at least 1 community (a-b-c and x-y-z may be merged by Leiden)
868        assert!(!communities.is_empty());
869    }
870
871    #[test]
872    fn test_empty_graph() {
873        let detector = CommunityDetector::default();
874        let communities = detector.detect(&[]).expect("should succeed");
875        assert!(communities.is_empty());
876    }
877}