Skip to main content

codesynapse_core/
cluster.rs

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        // Cohesion recomputed per chunk
406        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(&current, &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        // Default resolution should detect 2 main communities + maybe C1
463        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        // Each chunk has 1 internal edge out of 1 possible = cohesion 1.0
536        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        // Strong internal edges, weak cross edges
549        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        // Uniform weak edges
556        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        // Weighted with strong internal edges should split, uniform might merge
566        assert!(
567            weighted.len() >= uniform.len(),
568            "weighted clustering should produce different (more split) communities than unweighted"
569        );
570    }
571
572    // --- Edge case tests ---
573
574    #[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        // Very high resolution should not panic
608        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}