Skip to main content

gitcortex_mcp/mcp/
clustering.rs

1//! Label propagation community detection — GitCortex's no-LLM answer to
2//! Graphify's Leiden clustering. Deliberately a simpler algorithm: label
3//! propagation has no objective function to optimise (just "agree with your
4//! neighbours"), so it's cheap, has no extra dependency, and is easy to keep
5//! fully deterministic — a hard requirement for a tool whose entire value
6//! proposition is "same input, same answer, every time."
7//!
8//! Adjacency is undirected, built from `Contains` + `Calls` edges (same edge
9//! set `tour.rs` uses for its centrality signal — these two are the
10//! structurally/behaviourally "close" relationships; `Uses`/`Imports` are
11//! left out as weaker signals that would blur cluster boundaries).
12
13use std::collections::HashMap;
14
15use gitcortex_core::{
16    error::Result,
17    graph::{Edge, Node},
18    schema::EdgeKind,
19    store::GraphStore,
20};
21use serde::Serialize;
22
23use super::centrality::in_degree_by_calls;
24
25const MAX_ITERATIONS: u32 = 20;
26const DEFAULT_MIN_CLUSTER_SIZE: usize = 3;
27const DEFAULT_LIMIT: usize = 20;
28const MAX_LIMIT: usize = 100;
29
30#[derive(Debug, Clone, Serialize)]
31pub struct ClusterMember {
32    pub name: String,
33    pub qualified_name: String,
34    pub kind: String,
35    pub file: String,
36    pub start_line: u32,
37}
38
39#[derive(Debug, Clone, Serialize)]
40pub struct Cluster {
41    /// Representative member's qualified name — the highest in-degree symbol
42    /// in the cluster (ties broken by qualified_name), used as a human label.
43    pub label: String,
44    /// True member count — may exceed `members.len()` if the cluster was
45    /// truncated to `MAX_MEMBERS_PER_CLUSTER`.
46    pub size: usize,
47    pub members: Vec<ClusterMember>,
48}
49
50/// Cap members shown per cluster so one large, densely-connected cluster
51/// can't dump hundreds of entries — `size` stays the honest true count.
52const MAX_MEMBERS_PER_CLUSTER: usize = 25;
53
54/// Detect communities via synchronous label propagation, returning clusters
55/// of `min_cluster_size` or more members, ranked by size descending (ties
56/// broken by label).
57pub fn find_clusters<S: GraphStore + ?Sized>(
58    store: &S,
59    branch: &str,
60    min_cluster_size: Option<usize>,
61    limit: Option<usize>,
62) -> Result<Vec<Cluster>> {
63    let min_cluster_size = min_cluster_size.unwrap_or(DEFAULT_MIN_CLUSTER_SIZE).max(2);
64    let limit = limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
65
66    let nodes = store.list_all_nodes(branch)?;
67    let edges = store.list_all_edges(branch)?;
68
69    if nodes.is_empty() {
70        return Ok(Vec::new());
71    }
72
73    let in_degree = in_degree_by_calls(&edges);
74    let adjacency = build_undirected_adjacency(&edges);
75    let labels = propagate_labels(&nodes, &adjacency);
76
77    // Group node ids by final label.
78    let mut groups: HashMap<String, Vec<&Node>> = HashMap::new();
79    for n in &nodes {
80        let id = n.id.as_str();
81        let label = labels.get(&id).cloned().unwrap_or(id);
82        groups.entry(label).or_default().push(n);
83    }
84
85    let mut clusters: Vec<Cluster> = groups
86        .into_values()
87        .filter(|members| members.len() >= min_cluster_size)
88        .map(|mut members| {
89            // Deterministic member order within a cluster.
90            members.sort_by(|a, b| a.qualified_name.cmp(&b.qualified_name));
91
92            // Representative: highest in-degree, ties broken by qualified_name.
93            // members.len() >= min_cluster_size (>= 2) from the filter above,
94            // so max_by on a non-empty iterator always returns Some.
95            let representative = members
96                .iter()
97                .max_by(|a, b| {
98                    let deg_a = in_degree.get(&a.id.as_str()).copied().unwrap_or(0);
99                    let deg_b = in_degree.get(&b.id.as_str()).copied().unwrap_or(0);
100                    deg_a
101                        .cmp(&deg_b)
102                        .then_with(|| b.qualified_name.cmp(&a.qualified_name))
103                })
104                .unwrap_or_else(|| &members[0]);
105
106            Cluster {
107                label: representative.qualified_name.clone(),
108                size: members.len(),
109                members: members
110                    .iter()
111                    .take(MAX_MEMBERS_PER_CLUSTER)
112                    .map(|n| ClusterMember {
113                        name: n.name.clone(),
114                        qualified_name: n.qualified_name.clone(),
115                        kind: n.kind.to_string(),
116                        file: n.file.display().to_string(),
117                        start_line: n.span.start_line,
118                    })
119                    .collect(),
120            }
121        })
122        .collect();
123
124    clusters.sort_by(|a, b| b.size.cmp(&a.size).then_with(|| a.label.cmp(&b.label)));
125    clusters.truncate(limit);
126    Ok(clusters)
127}
128
129fn build_undirected_adjacency(edges: &[Edge]) -> HashMap<String, Vec<String>> {
130    let mut adjacency: HashMap<String, Vec<String>> = HashMap::new();
131    for e in edges {
132        if matches!(e.kind, EdgeKind::Contains | EdgeKind::Calls) {
133            let (a, b) = (e.src.as_str(), e.dst.as_str());
134            adjacency.entry(a.clone()).or_default().push(b.clone());
135            adjacency.entry(b).or_default().push(a);
136        }
137    }
138    adjacency
139}
140
141/// Synchronous label propagation: every node starts labelled with its own
142/// id; each pass, every node (visited in a fixed, deterministic order)
143/// adopts the most common label among its neighbours, ties broken by the
144/// lexicographically lowest candidate label's qualified name. Stops at
145/// convergence (no label changed) or `MAX_ITERATIONS`, whichever first.
146fn propagate_labels(
147    nodes: &[Node],
148    adjacency: &HashMap<String, Vec<String>>,
149) -> HashMap<String, String> {
150    let qname_of: HashMap<String, &str> = nodes
151        .iter()
152        .map(|n| (n.id.as_str(), n.qualified_name.as_str()))
153        .collect();
154
155    // Fixed, deterministic visiting order — independent of HashMap iteration
156    // order, which is what makes re-runs on identical input byte-identical.
157    let mut order: Vec<String> = nodes.iter().map(|n| n.id.as_str()).collect();
158    order.sort_by_key(|id| qname_of.get(id.as_str()).copied().unwrap_or(""));
159
160    let mut labels: HashMap<String, String> = nodes
161        .iter()
162        .map(|n| (n.id.as_str(), n.id.as_str()))
163        .collect();
164
165    for _ in 0..MAX_ITERATIONS {
166        let mut changed = false;
167        for id in &order {
168            let Some(neighbors) = adjacency.get(id) else {
169                continue;
170            };
171            if neighbors.is_empty() {
172                continue;
173            }
174
175            let mut counts: HashMap<&str, u32> = HashMap::new();
176            for nb in neighbors {
177                if let Some(label) = labels.get(nb) {
178                    *counts.entry(label.as_str()).or_insert(0) += 1;
179                }
180            }
181            if counts.is_empty() {
182                continue;
183            }
184
185            let best = counts
186                .into_iter()
187                .max_by(|(la, ca), (lb, cb)| {
188                    ca.cmp(cb).then_with(|| {
189                        let qa = qname_of.get(*la).copied().unwrap_or("");
190                        let qb = qname_of.get(*lb).copied().unwrap_or("");
191                        // Reverse: we want the LOWEST qualified_name to win
192                        // under max_by, so compare b against a.
193                        qb.cmp(qa)
194                    })
195                })
196                .map(|(label, _)| label.to_owned());
197
198            if let Some(new_label) = best {
199                if labels.get(id) != Some(&new_label) {
200                    labels.insert(id.clone(), new_label);
201                    changed = true;
202                }
203            }
204        }
205        if !changed {
206            break;
207        }
208    }
209
210    labels
211}
212
213#[cfg(test)]
214mod tests {
215    use std::path::PathBuf;
216
217    use gitcortex_core::graph::{Edge, NodeId, NodeMetadata, Span};
218    use gitcortex_core::schema::{EdgeKind, NodeKind};
219
220    use super::*;
221
222    fn make_node(name: &str) -> Node {
223        Node {
224            id: NodeId::new(),
225            kind: NodeKind::Function,
226            name: name.to_owned(),
227            qualified_name: name.to_owned(),
228            file: PathBuf::from("src/lib.rs"),
229            span: Span {
230                start_line: 1,
231                end_line: 5,
232            },
233            metadata: NodeMetadata::default(),
234        }
235    }
236
237    // ── adjacency ────────────────────────────────────────────────────────────
238
239    #[test]
240    fn build_undirected_adjacency_includes_both_directions() {
241        let src = NodeId::new();
242        let dst = NodeId::new();
243        let edges = vec![Edge::new(src.clone(), dst.clone(), EdgeKind::Calls)];
244        let adj = build_undirected_adjacency(&edges);
245        assert!(adj.get(&src.as_str()).unwrap().contains(&dst.as_str()));
246        assert!(adj.get(&dst.as_str()).unwrap().contains(&src.as_str()));
247    }
248
249    #[test]
250    fn build_undirected_adjacency_excludes_uses_edges() {
251        let src = NodeId::new();
252        let dst = NodeId::new();
253        let edges = vec![Edge::new(src.clone(), dst, EdgeKind::Uses)];
254        let adj = build_undirected_adjacency(&edges);
255        assert!(!adj.contains_key(&src.as_str()));
256    }
257
258    #[test]
259    fn build_undirected_adjacency_includes_contains_edges() {
260        let parent = NodeId::new();
261        let child = NodeId::new();
262        let edges = vec![Edge::new(parent.clone(), child.clone(), EdgeKind::Contains)];
263        let adj = build_undirected_adjacency(&edges);
264        assert!(adj.get(&parent.as_str()).unwrap().contains(&child.as_str()));
265        assert!(adj.get(&child.as_str()).unwrap().contains(&parent.as_str()));
266    }
267
268    // ── label propagation ────────────────────────────────────────────────────
269
270    #[test]
271    fn propagate_labels_empty_nodes_returns_empty() {
272        let labels = propagate_labels(&[], &HashMap::new());
273        assert!(labels.is_empty());
274    }
275
276    #[test]
277    fn propagate_labels_isolated_node_keeps_own_id() {
278        let node = make_node("lone_fn");
279        let id = node.id.as_str();
280        let labels = propagate_labels(&[node], &HashMap::new());
281        // Isolated node has no neighbours → never updated → stays as its own id.
282        assert_eq!(labels.get(&id), Some(&id));
283    }
284
285    #[test]
286    fn propagate_labels_two_connected_nodes_converge() {
287        let a = make_node("alpha");
288        let b = make_node("beta");
289        let a_id = a.id.as_str();
290        let b_id = b.id.as_str();
291
292        let mut adj = HashMap::new();
293        adj.insert(a_id.clone(), vec![b_id.clone()]);
294        adj.insert(b_id.clone(), vec![a_id.clone()]);
295
296        let labels = propagate_labels(&[a, b], &adj);
297        // Both must end up with the same label after convergence.
298        assert_eq!(
299            labels[&a_id], labels[&b_id],
300            "connected pair must converge to same label"
301        );
302    }
303
304    #[test]
305    fn propagate_labels_three_node_chain_converges() {
306        // A — B — C: all three should converge to the same community.
307        let a = make_node("aaa");
308        let b = make_node("bbb");
309        let c = make_node("ccc");
310        let a_id = a.id.as_str();
311        let b_id = b.id.as_str();
312        let c_id = c.id.as_str();
313
314        let mut adj = HashMap::new();
315        adj.insert(a_id.clone(), vec![b_id.clone()]);
316        adj.insert(b_id.clone(), vec![a_id.clone(), c_id.clone()]);
317        adj.insert(c_id.clone(), vec![b_id.clone()]);
318
319        let labels = propagate_labels(&[a, b, c], &adj);
320        assert_eq!(labels[&a_id], labels[&b_id]);
321        assert_eq!(labels[&b_id], labels[&c_id]);
322    }
323
324    #[test]
325    fn propagate_labels_deterministic_same_input_same_output() {
326        // Core guarantee: identical state → byte-identical output.
327        // Build a 6-node graph with two triangles that share one edge.
328        let nodes: Vec<Node> = ["p", "q", "r", "x", "y", "z"]
329            .iter()
330            .map(|n| make_node(n))
331            .collect();
332        let ids: Vec<String> = nodes.iter().map(|n| n.id.as_str()).collect();
333
334        let mut adj: HashMap<String, Vec<String>> = HashMap::new();
335        // Triangle 1: p-q-r
336        for (a, b) in [(&ids[0], &ids[1]), (&ids[1], &ids[2]), (&ids[2], &ids[0])] {
337            adj.entry(a.clone()).or_default().push(b.clone());
338            adj.entry(b.clone()).or_default().push(a.clone());
339        }
340        // Triangle 2: x-y-z
341        for (a, b) in [(&ids[3], &ids[4]), (&ids[4], &ids[5]), (&ids[5], &ids[3])] {
342            adj.entry(a.clone()).or_default().push(b.clone());
343            adj.entry(b.clone()).or_default().push(a.clone());
344        }
345
346        let run1 = propagate_labels(&nodes, &adj);
347        let run2 = propagate_labels(&nodes, &adj);
348        assert_eq!(run1, run2, "label propagation must be deterministic");
349    }
350
351    #[test]
352    fn propagate_labels_two_triangles_form_distinct_clusters() {
353        // p-q-r and x-y-z: disjoint → different final labels.
354        let nodes: Vec<Node> = ["p", "q", "r", "x", "y", "z"]
355            .iter()
356            .map(|n| make_node(n))
357            .collect();
358        let ids: Vec<String> = nodes.iter().map(|n| n.id.as_str()).collect();
359
360        let mut adj: HashMap<String, Vec<String>> = HashMap::new();
361        for (a, b) in [(&ids[0], &ids[1]), (&ids[1], &ids[2]), (&ids[2], &ids[0])] {
362            adj.entry(a.clone()).or_default().push(b.clone());
363            adj.entry(b.clone()).or_default().push(a.clone());
364        }
365        for (a, b) in [(&ids[3], &ids[4]), (&ids[4], &ids[5]), (&ids[5], &ids[3])] {
366            adj.entry(a.clone()).or_default().push(b.clone());
367            adj.entry(b.clone()).or_default().push(a.clone());
368        }
369
370        let labels = propagate_labels(&nodes, &adj);
371
372        let group1_label = &labels[&ids[0]];
373        let group2_label = &labels[&ids[3]];
374        // All within each triangle share a label.
375        assert_eq!(&labels[&ids[1]], group1_label);
376        assert_eq!(&labels[&ids[2]], group1_label);
377        assert_eq!(&labels[&ids[4]], group2_label);
378        assert_eq!(&labels[&ids[5]], group2_label);
379        // The two groups have different labels.
380        assert_ne!(
381            group1_label, group2_label,
382            "disjoint triangles must form distinct clusters"
383        );
384    }
385}