Skip to main content

llm_kernel/graph/algo/
community.rs

1//! Community detection: connected components and label propagation.
2//!
3//! Both algorithms treat the directed weighted graph as **undirected** — an
4//! edge connects its endpoints into the same community regardless of
5//! direction. Edges are traversed via the forward CSR; since every stored
6//! edge is visited from its source, that single pass already unions both
7//! endpoints, so no reverse traversal is needed for connected components.
8//! Label propagation reads neighbors in both directions.
9
10use super::csr::CsrGraph;
11
12/// Default maximum iteration count for [`label_propagation`].
13pub const LABEL_PROPAGATION_ITERS: usize = 10;
14
15/// Connected components via union-find (union-by-rank + path compression).
16///
17/// Returns one component-root index per node; nodes sharing a root are in the
18/// same component. `O((V + E) · α(V))` — effectively linear. The root is a
19/// node index (not a sequential component id); callers can relabel if needed.
20pub fn connected_components(g: &CsrGraph) -> Vec<u32> {
21    let n = g.node_count();
22    let mut parent: Vec<u32> = (0..n as u32).collect();
23    let mut rank: Vec<u32> = vec![0; n];
24
25    fn find(parent: &mut [u32], x: u32) -> u32 {
26        // Walk to the root.
27        let mut root = x;
28        while parent[root as usize] != root {
29            root = parent[root as usize];
30        }
31        // Path compression: point every node on the path straight at the root.
32        let mut cur = x;
33        while parent[cur as usize] != root {
34            let next = parent[cur as usize];
35            parent[cur as usize] = root;
36            cur = next;
37        }
38        root
39    }
40
41    // Forward pass unions every edge's source and target — each stored edge is
42    // visited once from its source, which is enough for an undirected view.
43    for i in g.node_indices() {
44        for (j, _) in g.out_neighbors(i) {
45            let ri = find(&mut parent, i);
46            let rj = find(&mut parent, j);
47            if ri == rj {
48                continue;
49            }
50            // Union by rank: smaller tree hangs under the larger.
51            let (a, b) = if rank[ri as usize] < rank[rj as usize] {
52                (rj, ri)
53            } else {
54                (ri, rj)
55            };
56            parent[b as usize] = a;
57            if rank[ri as usize] == rank[rj as usize] {
58                rank[a as usize] += 1;
59            }
60        }
61    }
62
63    // Final compression so every node holds its root directly.
64    for i in 0..n as u32 {
65        // Using a scoped helper: re-find mutably.
66        let root = {
67            let mut root = i;
68            while parent[root as usize] != root {
69                root = parent[root as usize];
70            }
71            root
72        };
73        let mut cur = i;
74        while parent[cur as usize] != root {
75            let next = parent[cur as usize];
76            parent[cur as usize] = root;
77            cur = next;
78        }
79    }
80    parent
81}
82
83/// Label propagation community detection (asynchronous, weighted).
84///
85/// Each node adopts the most frequent label among its undirected neighbors,
86/// breaking ties toward the smallest label for determinism. Iterates until no
87/// label changes or `max_iters` is reached. Weights bias a neighbor's vote by
88/// edge strength. Returns one community label per node index.
89pub fn label_propagation(g: &CsrGraph, max_iters: usize) -> Vec<u32> {
90    let n = g.node_count();
91    let mut label: Vec<u32> = (0..n as u32).collect();
92
93    for _ in 0..max_iters {
94        let mut changed = false;
95        for i in g.node_indices() {
96            // Undirected neighbors: out-edges + in-edges, each vote weighted.
97            let mut votes: Vec<(u32, f64)> = g
98                .out_neighbors(i)
99                .map(|(t, w)| (label[t as usize], w))
100                .chain(g.in_neighbors(i).map(|(s, w)| (label[s as usize], w)))
101                .collect();
102            if votes.is_empty() {
103                continue;
104            }
105            votes.sort_unstable_by_key(|&(l, _)| l);
106
107            // Aggregate weights per label, tracking the heaviest (ties → smallest).
108            let mut best_label = votes[0].0;
109            let mut best_weight = 0.0_f64;
110            let mut cur_label = votes[0].0;
111            let mut cur_weight = 0.0_f64;
112            for &(l, w) in &votes {
113                if l == cur_label {
114                    cur_weight += w;
115                } else {
116                    cur_label = l;
117                    cur_weight = w;
118                }
119                // Strictly-greater keeps the first (smallest label) on ties,
120                // since votes are sorted ascending by label.
121                if cur_weight > best_weight {
122                    best_label = cur_label;
123                    best_weight = cur_weight;
124                }
125            }
126
127            if best_label != label[i as usize] {
128                label[i as usize] = best_label;
129                changed = true;
130            }
131        }
132        if !changed {
133            break;
134        }
135    }
136    label
137}
138
139/// Label propagation with the default iteration cap (`LABEL_PROPAGATION_ITERS`).
140pub fn label_propagation_default(g: &CsrGraph) -> Vec<u32> {
141    label_propagation(g, LABEL_PROPAGATION_ITERS)
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::graph::algo::csr::CsrGraph;
148    use crate::graph::types::GraphEdge;
149
150    fn edge(id: &str, s: &str, t: &str, w: f64) -> GraphEdge {
151        GraphEdge {
152            id: id.into(),
153            source: s.into(),
154            target: t.into(),
155            relation: "related".into(),
156            weight: w,
157            ts: "2026-01-01T00:00:00Z".into(),
158        }
159    }
160
161    fn graph(nodes: &[&str], edges: &[GraphEdge]) -> CsrGraph {
162        let node_ids: Vec<String> = nodes.iter().map(|s| s.to_string()).collect();
163        CsrGraph::from_edges(&node_ids, edges)
164    }
165
166    #[test]
167    fn two_disjoint_components() {
168        let g = graph(
169            &["A", "B", "C", "D", "E"],
170            &[
171                edge("e1", "A", "B", 1.0),
172                edge("e2", "B", "C", 1.0),
173                edge("e3", "D", "E", 1.0),
174            ],
175        );
176        let comp = connected_components(&g);
177        let a = g.node_index("A").unwrap() as usize;
178        let b = g.node_index("B").unwrap() as usize;
179        let c = g.node_index("C").unwrap() as usize;
180        let d = g.node_index("D").unwrap() as usize;
181        let e = g.node_index("E").unwrap() as usize;
182        // {A,B,C} share a root; {D,E} share a root; the two differ.
183        assert_eq!(comp[a], comp[b]);
184        assert_eq!(comp[b], comp[c]);
185        assert_eq!(comp[d], comp[e]);
186        assert_ne!(comp[a], comp[d]);
187    }
188
189    #[test]
190    fn single_component_connected() {
191        let g = graph(
192            &["A", "B", "C"],
193            &[edge("e1", "A", "B", 1.0), edge("e2", "B", "C", 1.0)],
194        );
195        let comp = connected_components(&g);
196        let labels: std::collections::HashSet<u32> = comp.iter().copied().collect();
197        assert_eq!(labels.len(), 1, "fully connected graph has one component");
198    }
199
200    #[test]
201    fn direction_ignored_for_components() {
202        // A→B, C→B: undirected view unites all three.
203        let g = graph(
204            &["A", "B", "C"],
205            &[edge("e1", "A", "B", 1.0), edge("e2", "C", "B", 1.0)],
206        );
207        let comp = connected_components(&g);
208        let labels: std::collections::HashSet<u32> = comp.iter().copied().collect();
209        assert_eq!(labels.len(), 1);
210    }
211
212    #[test]
213    fn label_propagation_unites_connected_graph() {
214        // Path A-B-C converges to a single label.
215        let g = graph(
216            &["A", "B", "C"],
217            &[edge("e1", "A", "B", 1.0), edge("e2", "B", "C", 1.0)],
218        );
219        let label = label_propagation_default(&g);
220        let labels: std::collections::HashSet<u32> = label.iter().copied().collect();
221        assert_eq!(
222            labels.len(),
223            1,
224            "connected graph collapses to one community"
225        );
226    }
227
228    #[test]
229    fn label_propagation_splits_disjoint_components() {
230        // {A-B} and {C-D}, disconnected → two communities.
231        let g = graph(
232            &["A", "B", "C", "D"],
233            &[edge("e1", "A", "B", 1.0), edge("e2", "C", "D", 1.0)],
234        );
235        let label = label_propagation_default(&g);
236        let a = g.node_index("A").unwrap() as usize;
237        let b = g.node_index("B").unwrap() as usize;
238        let c = g.node_index("C").unwrap() as usize;
239        let d = g.node_index("D").unwrap() as usize;
240        assert_eq!(label[a], label[b]);
241        assert_eq!(label[c], label[d]);
242        assert_ne!(label[a], label[c]);
243        let distinct: std::collections::HashSet<u32> = label.iter().copied().collect();
244        assert_eq!(distinct.len(), 2);
245    }
246
247    #[test]
248    fn isolated_nodes_each_own_component() {
249        // No edges → every node its own component.
250        let g = graph(&["A", "B", "C"], &[]);
251        let comp = connected_components(&g);
252        let distinct: std::collections::HashSet<u32> = comp.iter().copied().collect();
253        assert_eq!(distinct.len(), 3);
254    }
255}