1use crate::error::Result;
2use crate::types::{Community, Edge, Node};
3use rand::seq::SliceRandom;
4use rand::SeedableRng;
5use std::collections::HashMap;
6
7pub struct CommunityDetector;
8
9impl CommunityDetector {
10 pub fn detect(
11 &self,
12 nodes: &[Node],
13 edges: &[Edge],
14 resolution: f64,
15 ) -> Result<Vec<Community>> {
16 if nodes.is_empty() {
17 return Ok(vec![]);
18 }
19 if edges.is_empty() {
20 return Ok(nodes
21 .iter()
22 .map(|n| Community {
23 id: 0,
24 nodes: vec![n.id.clone()],
25 cohesion: 1.0,
26 })
27 .collect());
28 }
29 Ok(leiden(nodes, edges, resolution))
30 }
31
32 pub fn detect_weighted(
33 &self,
34 nodes: &[Node],
35 edges: &[Edge],
36 resolution: f64,
37 ) -> Result<Vec<Community>> {
38 self.detect(nodes, edges, resolution)
39 }
40
41 pub fn exclude_hubs(
42 &self,
43 nodes: &[Node],
44 edges: &[Edge],
45 max_degree: usize,
46 ) -> (Vec<Node>, Vec<Edge>, Vec<Node>) {
47 let mut degree: HashMap<&str, usize> = HashMap::new();
48 for edge in edges {
49 *degree.entry(edge.source.as_str()).or_insert(0) += 1;
50 *degree.entry(edge.target.as_str()).or_insert(0) += 1;
51 }
52
53 let hub_ids: std::collections::HashSet<&str> = degree
54 .iter()
55 .filter(|(_, &d)| d > max_degree)
56 .map(|(&id, _)| id)
57 .collect();
58
59 let non_hubs: Vec<Node> = nodes
60 .iter()
61 .filter(|n| !hub_ids.contains(n.id.as_str()))
62 .cloned()
63 .collect();
64
65 let hubs: Vec<Node> = nodes
66 .iter()
67 .filter(|n| hub_ids.contains(n.id.as_str()))
68 .cloned()
69 .collect();
70
71 let filtered_edges: Vec<Edge> = edges
72 .iter()
73 .filter(|e| {
74 !hub_ids.contains(e.source.as_str()) && !hub_ids.contains(e.target.as_str())
75 })
76 .cloned()
77 .collect();
78
79 (non_hubs, filtered_edges, hubs)
80 }
81
82 pub fn split_oversized(
83 &self,
84 nodes: &[Node],
85 edges: &[Edge],
86 communities: Vec<Community>,
87 max_size: usize,
88 ) -> Vec<Community> {
89 let mut result = Vec::new();
90 for comm in communities {
91 if comm.nodes.len() <= max_size {
92 result.push(comm);
93 } else {
94 for chunk in comm.nodes.chunks(max_size) {
95 let cohesion = Self::compute_cohesion(nodes, edges, chunk);
96 result.push(Community {
97 id: result.len(),
98 nodes: chunk.to_vec(),
99 cohesion,
100 });
101 }
102 }
103 }
104 result
105 }
106
107 pub fn remap_communities_to_previous(
108 &self,
109 current: &[Community],
110 previous: &[Community],
111 ) -> HashMap<usize, usize> {
112 let mut remap = HashMap::new();
113 for (i, comm) in current.iter().enumerate() {
114 let mut best = i;
115 let mut max_overlap = 0;
116 let cur_set: std::collections::HashSet<&str> =
117 comm.nodes.iter().map(|s| s.as_str()).collect();
118 for prev_comm in previous {
119 let overlap = prev_comm
120 .nodes
121 .iter()
122 .filter(|n| cur_set.contains(n.as_str()))
123 .count();
124 if overlap > max_overlap {
125 max_overlap = overlap;
126 best = prev_comm.id;
127 }
128 }
129 remap.insert(comm.id, best);
130 }
131 remap
132 }
133
134 fn compute_cohesion(_nodes: &[Node], edges: &[Edge], community_nodes: &[String]) -> f64 {
135 let k = community_nodes.len();
136 if k <= 1 {
137 return 1.0;
138 }
139 let node_set: std::collections::HashSet<&str> =
140 community_nodes.iter().map(|s| s.as_str()).collect();
141 let max_possible = k * (k - 1) / 2;
142 let mut internal = 0usize;
143 for edge in edges {
144 if node_set.contains(edge.source.as_str()) && node_set.contains(edge.target.as_str()) {
145 internal += 1;
146 }
147 }
148 internal as f64 / max_possible as f64
149 }
150}
151
152fn leiden(nodes: &[Node], edges: &[Edge], resolution: f64) -> Vec<Community> {
153 let n = nodes.len();
154 if n == 0 {
155 return vec![];
156 }
157
158 let node_ids: Vec<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
159 let id_to_idx: HashMap<&str, usize> = node_ids
160 .iter()
161 .enumerate()
162 .map(|(i, id)| (*id, i))
163 .collect();
164
165 let mut adjacency: Vec<Vec<(usize, f64)>> = vec![vec![]; n];
166 let mut total_edge_weight = 0.0;
167 for edge in edges {
168 if let (Some(&si), Some(&ti)) = (
169 id_to_idx.get(edge.source.as_str()),
170 id_to_idx.get(edge.target.as_str()),
171 ) {
172 let w = edge.weight;
173 adjacency[si].push((ti, w));
174 if si != ti {
175 adjacency[ti].push((si, w));
176 }
177 total_edge_weight += w;
178 }
179 }
180
181 if total_edge_weight == 0.0 {
182 return nodes
183 .iter()
184 .map(|n| Community {
185 id: 0,
186 nodes: vec![n.id.clone()],
187 cohesion: 1.0,
188 })
189 .collect();
190 }
191
192 let m2 = 2.0 * total_edge_weight;
193
194 let mut community: Vec<usize> = (0..n).collect();
195 let mut comm_deg: Vec<f64> = (0..n)
196 .map(|i| adjacency[i].iter().map(|&(_, w)| w).sum::<f64>())
197 .collect();
198 let node_deg: Vec<f64> = comm_deg.clone();
199
200 let mut rng = rand::rngs::StdRng::from_entropy();
201
202 for _iter in 0..15 {
203 let mut improved = false;
204 let mut order: Vec<usize> = (0..n).collect();
205 order.shuffle(&mut rng);
206
207 for &node in &order {
208 let curr_comm = community[node];
209 let k_i = node_deg[node];
210
211 let mut best_comm = curr_comm;
212 let mut best_delta = 0.0;
213 let curr_sigma_tot = comm_deg[curr_comm];
214
215 let curr_ki_in: f64 = adjacency[node]
216 .iter()
217 .filter(|&&(nb, _)| community[nb] == curr_comm)
218 .map(|&(_, w)| w)
219 .sum();
220
221 let mut neighbors: std::collections::HashSet<usize> = std::collections::HashSet::new();
222 for &(nb, _) in &adjacency[node] {
223 neighbors.insert(community[nb]);
224 }
225
226 for &cand_comm in &neighbors {
227 if cand_comm == curr_comm {
228 continue;
229 }
230 let cand_ki_in: f64 = adjacency[node]
231 .iter()
232 .filter(|&&(nb, _)| community[nb] == cand_comm)
233 .map(|&(_, w)| w)
234 .sum();
235 let cand_sigma_tot = comm_deg[cand_comm];
236
237 let delta = (cand_ki_in - resolution * cand_sigma_tot * k_i / m2)
238 - (curr_ki_in - resolution * curr_sigma_tot * k_i / m2);
239
240 if delta > best_delta {
241 best_delta = delta;
242 best_comm = cand_comm;
243 }
244 }
245
246 if best_comm != curr_comm {
247 improved = true;
248 community[node] = best_comm;
249 comm_deg[curr_comm] -= k_i;
250 comm_deg[best_comm] += k_i;
251 }
252 }
253
254 if !improved {
255 break;
256 }
257 }
258
259 let mut comm_index: HashMap<usize, usize> = HashMap::new();
260 let mut next = 0;
261 for &c in &community {
262 comm_index.entry(c).or_insert_with(|| {
263 let id = next;
264 next += 1;
265 id
266 });
267 }
268
269 let mut comm_map: HashMap<usize, Vec<String>> = HashMap::new();
270 for (i, &c) in community.iter().enumerate() {
271 let new_id = comm_index[&c];
272 comm_map
273 .entry(new_id)
274 .or_default()
275 .push(node_ids[i].to_string());
276 }
277
278 comm_map
279 .into_iter()
280 .map(|(id, nodes_c)| Community {
281 id,
282 nodes: nodes_c,
283 cohesion: 1.0,
284 })
285 .collect()
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use std::collections::HashMap;
292
293 fn make_node(id: &str, label: &str) -> Node {
294 Node {
295 id: id.to_string(),
296 label: label.to_string(),
297 file_type: "code".to_string(),
298 source_file: "test.py".to_string(),
299 source_location: None,
300 community: None,
301 rationale: None,
302 docstring: None,
303 metadata: HashMap::new(),
304 }
305 }
306
307 fn make_edge(src: &str, tgt: &str, weight: f64) -> Edge {
308 Edge {
309 source: src.to_string(),
310 target: tgt.to_string(),
311 relation: "connects".to_string(),
312 confidence: "EXTRACTED".to_string(),
313 source_file: Some("test.py".to_string()),
314 weight,
315 context: None,
316 }
317 }
318
319 #[test]
320 fn test_cluster_empty() {
321 let detector = CommunityDetector;
322 let communities = detector.detect(&[], &[], 1.0).unwrap();
323 assert!(communities.is_empty());
324 }
325
326 #[test]
327 fn test_cluster_isolates() {
328 let nodes = vec![
329 make_node("a", "A"),
330 make_node("b", "B"),
331 make_node("c", "C"),
332 ];
333 let detector = CommunityDetector;
334 let communities = detector.detect(&nodes, &[], 1.0).unwrap();
335 assert!(communities.len() == 3);
336 }
337
338 #[test]
339 fn test_cluster_deterministic() {
340 let nodes = vec![make_node("a", "A"), make_node("b", "B")];
341 let edges = vec![make_edge("a", "b", 1.0)];
342 let detector = CommunityDetector;
343 let r1 = detector.detect(&nodes, &edges, 1.0).unwrap();
344 let r2 = detector.detect(&nodes, &edges, 1.0).unwrap();
345 assert_eq!(r1.len(), r2.len());
346 }
347
348 #[test]
349 fn test_cluster_two_communities() {
350 let nodes = vec![
351 make_node("a1", "A1"),
352 make_node("a2", "A2"),
353 make_node("b1", "B1"),
354 make_node("b2", "B2"),
355 ];
356 let edges = vec![
357 make_edge("a1", "a2", 10.0),
358 make_edge("b1", "b2", 10.0),
359 make_edge("a1", "b1", 1.0),
360 make_edge("a2", "b2", 1.0),
361 ];
362 let detector = CommunityDetector;
363 let communities = detector.detect(&nodes, &edges, 1.0).unwrap();
364 assert_eq!(communities.len(), 2);
365 }
366
367 #[test]
368 fn test_cluster_resolution_parameter() {
369 let nodes = vec![
370 make_node("a1", "A1"),
371 make_node("a2", "A2"),
372 make_node("b1", "B1"),
373 make_node("b2", "B2"),
374 ];
375 let edges = vec![
376 make_edge("a1", "a2", 10.0),
377 make_edge("b1", "b2", 10.0),
378 make_edge("a1", "b1", 1.0),
379 make_edge("a2", "b2", 1.0),
380 ];
381 let detector = CommunityDetector;
382 let low_res = detector.detect(&nodes, &edges, 0.1).unwrap();
383 let high_res = detector.detect(&nodes, &edges, 5.0).unwrap();
384 assert!(high_res.len() >= low_res.len());
385 }
386
387 #[test]
388 fn test_split_oversized() {
389 let nodes = vec![
390 make_node("a", "A"),
391 make_node("b", "B"),
392 make_node("c", "C"),
393 make_node("d", "D"),
394 ];
395 let edges = vec![make_edge("a", "b", 1.0), make_edge("c", "d", 1.0)];
396 let communities = vec![Community {
397 id: 0,
398 nodes: vec!["a".into(), "b".into(), "c".into(), "d".into()],
399 cohesion: 0.5,
400 }];
401 let detector = CommunityDetector;
402 let split = detector.split_oversized(&nodes, &edges, communities, 2);
403 assert_eq!(split.len(), 2);
404 assert_eq!(split[0].nodes.len(), 2);
405 assert!(split[0].cohesion > split[1].cohesion || split[1].cohesion > 0.0);
407 }
408
409 #[test]
410 fn test_remap_communities() {
411 let current = vec![Community {
412 id: 0,
413 nodes: vec!["a".into(), "b".into()],
414 cohesion: 1.0,
415 }];
416 let previous = vec![Community {
417 id: 5,
418 nodes: vec!["a".into(), "c".into()],
419 cohesion: 1.0,
420 }];
421 let detector = CommunityDetector;
422 let remap = detector.remap_communities_to_previous(¤t, &previous);
423 assert!(remap.contains_key(&0));
424 }
425
426 #[test]
427 fn test_cluster_covers_all_nodes() {
428 let nodes = vec![
429 make_node("x", "X"),
430 make_node("y", "Y"),
431 make_node("z", "Z"),
432 ];
433 let edges = vec![make_edge("x", "y", 1.0)];
434 let detector = CommunityDetector;
435 let communities = detector.detect(&nodes, &edges, 1.0).unwrap();
436 let all: Vec<&str> = communities
437 .iter()
438 .flat_map(|c| c.nodes.iter().map(|s| s.as_str()))
439 .collect();
440 assert_eq!(all.len(), 3);
441 }
442
443 #[test]
444 fn test_cluster_resolution_default() {
445 let nodes = vec![
446 make_node("a1", "A1"),
447 make_node("a2", "A2"),
448 make_node("b1", "B1"),
449 make_node("b2", "B2"),
450 make_node("c1", "C1"),
451 ];
452 let edges = vec![
453 make_edge("a1", "a2", 10.0),
454 make_edge("b1", "b2", 10.0),
455 make_edge("a1", "b1", 1.0),
456 make_edge("a2", "b2", 1.0),
457 make_edge("c1", "a1", 0.1),
458 make_edge("c1", "b1", 0.1),
459 ];
460 let detector = CommunityDetector;
461 let communities = detector.detect(&nodes, &edges, 1.0).unwrap();
462 assert!(
464 communities.len() >= 2,
465 "default resolution should find communities"
466 );
467 }
468
469 #[test]
470 fn test_cluster_resolution_low() {
471 let nodes = vec![
472 make_node("a1", "A1"),
473 make_node("a2", "A2"),
474 make_node("b1", "B1"),
475 make_node("b2", "B2"),
476 ];
477 let edges = vec![
478 make_edge("a1", "a2", 10.0),
479 make_edge("b1", "b2", 10.0),
480 make_edge("a1", "b1", 1.0),
481 make_edge("a2", "b2", 1.0),
482 ];
483 let detector = CommunityDetector;
484 let low = detector.detect(&nodes, &edges, 0.1).unwrap();
485 let high = detector.detect(&nodes, &edges, 2.0).unwrap();
486 assert!(
487 low.len() <= high.len(),
488 "low resolution should merge communities (got {} vs {})",
489 low.len(),
490 high.len()
491 );
492 }
493
494 #[test]
495 fn test_cluster_exclude_hubs() {
496 let nodes = vec![
497 make_node("hub", "Hub"),
498 make_node("a", "A"),
499 make_node("b", "B"),
500 make_node("c", "C"),
501 ];
502 let edges = vec![
503 make_edge("hub", "a", 1.0),
504 make_edge("hub", "b", 1.0),
505 make_edge("hub", "c", 1.0),
506 make_edge("a", "b", 1.0),
507 ];
508 let detector = CommunityDetector;
509 let (non_hubs, filtered_edges, hubs) = detector.exclude_hubs(&nodes, &edges, 2);
510 assert_eq!(hubs.len(), 1);
511 assert_eq!(hubs[0].id, "hub");
512 assert_eq!(non_hubs.len(), 3);
513 assert_eq!(filtered_edges.len(), 1);
514 assert_eq!(filtered_edges[0].source, "a");
515 assert_eq!(filtered_edges[0].target, "b");
516 }
517
518 #[test]
519 fn test_cluster_cohesion_split() {
520 let nodes = vec![
521 make_node("a", "A"),
522 make_node("b", "B"),
523 make_node("c", "C"),
524 make_node("d", "D"),
525 ];
526 let edges = vec![make_edge("a", "b", 1.0), make_edge("c", "d", 1.0)];
527 let communities = vec![Community {
528 id: 0,
529 nodes: vec!["a".into(), "b".into(), "c".into(), "d".into()],
530 cohesion: 0.17,
531 }];
532 let detector = CommunityDetector;
533 let split = detector.split_oversized(&nodes, &edges, communities, 2);
534 assert_eq!(split.len(), 2);
535 assert!((split[0].cohesion - 1.0).abs() < 0.01);
537 assert!((split[1].cohesion - 1.0).abs() < 0.01);
538 }
539
540 #[test]
541 fn test_cluster_weighted() {
542 let nodes = vec![
543 make_node("a1", "A1"),
544 make_node("a2", "A2"),
545 make_node("b1", "B1"),
546 make_node("b2", "B2"),
547 ];
548 let weighted_edges = vec![
550 make_edge("a1", "a2", 100.0),
551 make_edge("b1", "b2", 100.0),
552 make_edge("a1", "b1", 1.0),
553 make_edge("a2", "b2", 1.0),
554 ];
555 let uniform_edges = vec![
557 make_edge("a1", "a2", 1.0),
558 make_edge("b1", "b2", 1.0),
559 make_edge("a1", "b1", 1.0),
560 make_edge("a2", "b2", 1.0),
561 ];
562 let detector = CommunityDetector;
563 let weighted = detector.detect(&nodes, &weighted_edges, 1.0).unwrap();
564 let uniform = detector.detect(&nodes, &uniform_edges, 1.0).unwrap();
565 assert!(
567 weighted.len() >= uniform.len(),
568 "weighted clustering should produce different (more split) communities than unweighted"
569 );
570 }
571
572 #[test]
575 fn test_cluster_single_node() {
576 let nodes = vec![make_node("a", "A")];
577 let detector = CommunityDetector;
578 let communities = detector.detect(&nodes, &[], 1.0).unwrap();
579 assert_eq!(communities.len(), 1);
580 assert_eq!(communities[0].nodes.len(), 1);
581 }
582
583 #[test]
584 fn test_cluster_single_edge() {
585 let nodes = vec![make_node("a", "A"), make_node("b", "B")];
586 let edges = vec![make_edge("a", "b", 1.0)];
587 let detector = CommunityDetector;
588 let communities = detector.detect(&nodes, &edges, 1.0).unwrap();
589 assert_eq!(communities.len(), 1, "two connected nodes → one community");
590 assert_eq!(communities[0].nodes.len(), 2);
591 }
592
593 #[test]
594 fn test_cluster_zero_weight_edges() {
595 let nodes = vec![make_node("a", "A"), make_node("b", "B")];
596 let edges = vec![make_edge("a", "b", 0.0)];
597 let detector = CommunityDetector;
598 let communities = detector.detect(&nodes, &edges, 1.0).unwrap();
599 assert!(communities.len() <= 2);
600 }
601
602 #[test]
603 fn test_cluster_high_resolution_not_panics() {
604 let nodes = vec![make_node("a", "A"), make_node("b", "B")];
605 let edges = vec![make_edge("a", "b", 1.0)];
606 let detector = CommunityDetector;
607 let communities = detector.detect(&nodes, &edges, 100.0).unwrap();
609 assert!(
610 !communities.is_empty(),
611 "high resolution still produces communities"
612 );
613 }
614
615 #[test]
616 fn test_cluster_split_oversized_empty() {
617 let detector = CommunityDetector;
618 let split = detector.split_oversized(&[], &[], vec![], 5);
619 assert!(split.is_empty());
620 }
621
622 #[test]
623 fn test_cluster_remap_empty() {
624 let detector = CommunityDetector;
625 let remap = detector.remap_communities_to_previous(&[], &[]);
626 assert!(remap.is_empty());
627 }
628}