Skip to main content

agentstatedeveloper_core/
community.rs

1//! Community detection over the call graph (Plan competitive-harvest t-009).
2//!
3//! Partitions symbols into functional communities — groups that call each other
4//! more than they call outside the group. Used to power `asd architecture`'s
5//! "clusters" (a call-graph view, complementary to the path-based layer view).
6//!
7//! ## Why Louvain's local-move phase, not Label Propagation
8//!
9//! Label propagation is simpler but *collapses on bridged cliques*: a single
10//! edge between two otherwise-dense modules causes one to adopt the other's
11//! label, merging them. Code call graphs are full of such thin bridges, so LPA
12//! would report one giant community. The modularity gain Louvain optimizes
13//! resists this — joining a dense community through one edge barely raises
14//! modularity, so dense modules stay separate. We run the local-moving phase
15//! (one Louvain level); the multi-level aggregation that further coarsens
16//! communities is a possible future refinement.
17//!
18//! Deterministic: nodes are visited in input order and ties break to the
19//! lowest community id, so the same graph always yields the same partition.
20
21use std::collections::HashMap;
22
23/// Detect communities over an undirected graph. `nodes` should be passed in a
24/// stable (e.g. sorted) order for reproducibility. `edges` are unordered pairs;
25/// self-loops and edges to unknown nodes are ignored, duplicates collapse.
26/// Returns a dense `node → community_id` map.
27pub fn detect_communities(nodes: &[String], edges: &[(String, String)]) -> HashMap<String, usize> {
28    let n = nodes.len();
29    let idx: HashMap<&str, usize> = nodes
30        .iter()
31        .enumerate()
32        .map(|(i, s)| (s.as_str(), i))
33        .collect();
34
35    // Dedup'd undirected adjacency (simple graph).
36    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
37    {
38        let mut seen: std::collections::HashSet<(usize, usize)> = std::collections::HashSet::new();
39        for (a, b) in edges {
40            let (Some(&ia), Some(&ib)) = (idx.get(a.as_str()), idx.get(b.as_str())) else {
41                continue;
42            };
43            if ia == ib {
44                continue;
45            }
46            let key = if ia < ib { (ia, ib) } else { (ib, ia) };
47            if seen.insert(key) {
48                adj[ia].push(ib);
49                adj[ib].push(ia);
50            }
51        }
52    }
53
54    let deg: Vec<f64> = adj.iter().map(|a| a.len() as f64).collect();
55    let two_m: f64 = deg.iter().sum();
56    if two_m == 0.0 {
57        // No edges — every node is its own community.
58        return nodes
59            .iter()
60            .enumerate()
61            .map(|(i, s)| (s.clone(), i))
62            .collect();
63    }
64
65    let mut comm: Vec<usize> = (0..n).collect();
66    let mut sigma_tot: Vec<f64> = deg.clone(); // community → sum of member degrees
67
68    let mut improved = true;
69    let mut guard = 0;
70    while improved && guard < 50 {
71        improved = false;
72        guard += 1;
73        for i in 0..n {
74            if adj[i].is_empty() {
75                continue;
76            }
77            let ci = comm[i];
78            let ki = deg[i];
79            // Detach i from its community.
80            sigma_tot[ci] -= ki;
81            comm[i] = usize::MAX;
82
83            // Sum of edges from i into each candidate community (k_{i,in}).
84            let mut k_in: HashMap<usize, f64> = HashMap::new();
85            for &j in &adj[i] {
86                if comm[j] != usize::MAX {
87                    *k_in.entry(comm[j]).or_default() += 1.0;
88                }
89            }
90
91            // Best community by modularity gain ∝ k_{i,in} − Σtot·k_i/(2m).
92            // Start from staying in ci; ties (within ε) break to the lowest id.
93            let gain = |c: usize| k_in.get(&c).copied().unwrap_or(0.0) - sigma_tot[c] * ki / two_m;
94            let mut best_c = ci;
95            let mut best_gain = gain(ci);
96            for &c in k_in.keys() {
97                let g = gain(c);
98                if g > best_gain + 1e-12 || ((g - best_gain).abs() <= 1e-12 && c < best_c) {
99                    best_gain = g;
100                    best_c = c;
101                }
102            }
103
104            comm[i] = best_c;
105            sigma_tot[best_c] += ki;
106            if best_c != ci {
107                improved = true;
108            }
109        }
110    }
111
112    // Canonicalize to dense ids, assigned in first-seen (input) order.
113    let mut canon: HashMap<usize, usize> = HashMap::new();
114    let mut next = 0usize;
115    let mut out = HashMap::with_capacity(n);
116    for (i, s) in nodes.iter().enumerate() {
117        let id = *canon.entry(comm[i]).or_insert_with(|| {
118            let v = next;
119            next += 1;
120            v
121        });
122        out.insert(s.clone(), id);
123    }
124    out
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    fn nodes(ns: &[&str]) -> Vec<String> {
132        ns.iter().map(|s| s.to_string()).collect()
133    }
134    fn edges(es: &[(&str, &str)]) -> Vec<(String, String)> {
135        es.iter()
136            .map(|(a, b)| (a.to_string(), b.to_string()))
137            .collect()
138    }
139    fn num_communities(m: &HashMap<String, usize>) -> usize {
140        m.values().collect::<std::collections::HashSet<_>>().len()
141    }
142
143    #[test]
144    fn two_triangles_one_bridge_stay_separate() {
145        // The case that breaks label propagation: two triangles joined by a
146        // single edge c—d must remain two communities.
147        let ns = nodes(&["a", "b", "c", "d", "e", "f"]);
148        let es = edges(&[
149            ("a", "b"),
150            ("b", "c"),
151            ("a", "c"), // triangle 1
152            ("d", "e"),
153            ("e", "f"),
154            ("d", "f"), // triangle 2
155            ("c", "d"), // bridge
156        ]);
157        let comm = detect_communities(&ns, &es);
158        assert_eq!(num_communities(&comm), 2, "{comm:?}");
159        assert_eq!(comm["a"], comm["b"]);
160        assert_eq!(comm["b"], comm["c"]);
161        assert_eq!(comm["d"], comm["e"]);
162        assert_eq!(comm["e"], comm["f"]);
163        assert_ne!(comm["c"], comm["d"]);
164    }
165
166    #[test]
167    fn single_clique_is_one_community() {
168        let ns = nodes(&["a", "b", "c", "d"]);
169        let es = edges(&[
170            ("a", "b"),
171            ("a", "c"),
172            ("a", "d"),
173            ("b", "c"),
174            ("b", "d"),
175            ("c", "d"),
176        ]);
177        assert_eq!(num_communities(&detect_communities(&ns, &es)), 1);
178    }
179
180    #[test]
181    fn isolated_nodes_each_their_own() {
182        let ns = nodes(&["a", "b", "c"]);
183        let comm = detect_communities(&ns, &[]);
184        assert_eq!(num_communities(&comm), 3);
185    }
186
187    #[test]
188    fn deterministic_across_runs() {
189        let ns = nodes(&["a", "b", "c", "d", "e", "f"]);
190        let es = edges(&[
191            ("a", "b"),
192            ("b", "c"),
193            ("a", "c"),
194            ("d", "e"),
195            ("e", "f"),
196            ("d", "f"),
197            ("c", "d"),
198        ]);
199        assert_eq!(detect_communities(&ns, &es), detect_communities(&ns, &es));
200    }
201
202    #[test]
203    fn ignores_self_loops_and_unknown_nodes() {
204        let ns = nodes(&["a", "b"]);
205        let es = edges(&[("a", "a"), ("a", "b"), ("a", "ghost")]);
206        let comm = detect_communities(&ns, &es);
207        assert_eq!(comm["a"], comm["b"]); // connected → same community
208        assert_eq!(num_communities(&comm), 1);
209    }
210}