1use std::collections::BTreeMap;
22
23#[derive(Clone, Debug, PartialEq)]
26pub struct Communities {
27 pub of: Vec<usize>,
28 pub count: usize,
29 pub modularity: f32,
30}
31
32#[must_use]
36pub fn louvain(n: usize, edges: &[(usize, usize)]) -> Communities {
37 let mut adj: Vec<BTreeMap<usize, f32>> = vec![BTreeMap::new(); n];
39 let mut deg = vec![0.0f32; n];
40 let mut m2 = 0.0f32; for &(a, b) in edges {
42 if a == b || a >= n || b >= n {
43 continue;
44 }
45 *adj[a].entry(b).or_insert(0.0) += 1.0;
46 *adj[b].entry(a).or_insert(0.0) += 1.0;
47 }
48 for i in 0..n {
49 deg[i] = adj[i].values().sum();
50 m2 += deg[i];
51 }
52 if m2 == 0.0 {
53 return Communities { of: (0..n).collect(), count: n, modularity: 0.0 };
55 }
56
57 let mut comm: Vec<usize> = (0..n).collect();
58 let mut sigma_tot: Vec<f32> = deg.clone(); loop {
61 let mut moved = false;
62 for i in 0..n {
63 let ci = comm[i];
64 sigma_tot[ci] -= deg[i];
66 let mut k_in: BTreeMap<usize, f32> = BTreeMap::new();
68 for (&j, &w) in &adj[i] {
69 if j != i {
70 *k_in.entry(comm[j]).or_insert(0.0) += w;
71 }
72 }
73 let mut best_c = ci;
75 let mut best_gain = k_in.get(&ci).copied().unwrap_or(0.0) - sigma_tot[ci] * deg[i] / m2;
76 for (&c, &kin) in &k_in {
77 let gain = kin - sigma_tot[c] * deg[i] / m2;
78 if gain > best_gain + 1e-9 {
79 best_gain = gain;
80 best_c = c;
81 }
82 }
83 sigma_tot[best_c] += deg[i];
84 if best_c != ci {
85 comm[i] = best_c;
86 moved = true;
87 }
88 }
89 if !moved {
90 break;
91 }
92 }
93
94 let mut remap: BTreeMap<usize, usize> = BTreeMap::new();
96 let of: Vec<usize> = comm
97 .iter()
98 .map(|&c| {
99 let next = remap.len();
100 *remap.entry(c).or_insert(next)
101 })
102 .collect();
103 let count = remap.len();
104 let modularity = modularity(n, edges, &of);
105 Communities { of, count, modularity }
106}
107
108#[must_use]
110pub fn modularity(n: usize, edges: &[(usize, usize)], of: &[usize]) -> f32 {
111 let mut deg = vec![0.0f32; n];
112 let mut m2 = 0.0f32;
113 let mut a_in: BTreeMap<(usize, usize), f32> = BTreeMap::new();
114 for &(a, b) in edges {
115 if a == b || a >= n || b >= n {
116 continue;
117 }
118 deg[a] += 1.0;
119 deg[b] += 1.0;
120 m2 += 2.0;
121 *a_in.entry((a.min(b), a.max(b))).or_insert(0.0) += 1.0;
122 }
123 if m2 == 0.0 {
124 return 0.0;
125 }
126 let mut q = 0.0f32;
127 for (&(a, b), &w) in &a_in {
129 if of[a] == of[b] {
130 q += 2.0 * w;
131 }
132 }
133 let mut by_comm: BTreeMap<usize, f32> = BTreeMap::new();
135 for i in 0..n {
136 *by_comm.entry(of[i]).or_insert(0.0) += deg[i];
137 }
138 let mut expected = 0.0f32;
139 for &s in by_comm.values() {
140 expected += s * s / m2;
141 }
142 (q - expected) / m2
143}
144
145#[derive(Clone, Debug, PartialEq)]
148pub struct MetaNode {
149 pub community: usize,
150 pub members: Vec<usize>,
151 pub pos: [f32; 2],
152 pub weight: f32,
153}
154
155#[derive(Clone, Debug, PartialEq)]
158pub struct MetaGraph {
159 pub metas: Vec<MetaNode>,
160 pub edges: Vec<(usize, usize, f32)>,
161}
162
163#[must_use]
166pub fn meta_graph(positions: &[[f32; 2]], edges: &[(usize, usize)], comms: &Communities) -> MetaGraph {
167 let mut members: Vec<Vec<usize>> = vec![Vec::new(); comms.count];
168 for (i, &c) in comms.of.iter().enumerate() {
169 members[c].push(i);
170 }
171 let metas = members
172 .into_iter()
173 .enumerate()
174 .map(|(c, mem)| {
175 let (mut sx, mut sy) = (0.0f32, 0.0f32);
176 for &i in &mem {
177 sx += positions[i][0];
178 sy += positions[i][1];
179 }
180 let k = mem.len().max(1) as f32;
181 MetaNode { community: c, members: mem.clone(), pos: [sx / k, sy / k], weight: mem.len() as f32 }
182 })
183 .collect();
184 let mut agg: BTreeMap<(usize, usize), f32> = BTreeMap::new();
185 for &(a, b) in edges {
186 if a >= comms.of.len() || b >= comms.of.len() {
187 continue;
188 }
189 let (ca, cb) = (comms.of[a], comms.of[b]);
190 if ca != cb {
191 *agg.entry((ca.min(cb), ca.max(cb))).or_insert(0.0) += 1.0;
192 }
193 }
194 let edges = agg.into_iter().map(|((a, b), w)| (a, b, w)).collect();
195 MetaGraph { metas, edges }
196}
197
198#[must_use]
204pub fn fracture_t(zoom: f32, collapse_below: f32, span: f32, ease: impl Fn(f32) -> f32) -> f32 {
205 let span = span.max(1e-4);
206 let raw = ((zoom - collapse_below) / span).clamp(0.0, 1.0);
207 ease(raw).clamp(0.0, 1.0)
208}
209
210#[must_use]
214pub fn fractured_positions(positions: &[[f32; 2]], comms: &Communities, meta: &MetaGraph, t: f32) -> Vec<[f32; 2]> {
215 let t = t.clamp(0.0, 1.0);
216 positions
217 .iter()
218 .enumerate()
219 .map(|(i, p)| {
220 let c = comms.of[i];
221 let m = meta.metas[c].pos;
222 [m[0] + (p[0] - m[0]) * t, m[1] + (p[1] - m[1]) * t]
223 })
224 .collect()
225}
226
227#[must_use]
230pub fn visible_count(comms: &Communities, t: f32) -> usize {
231 if t <= 0.0 { comms.count } else { comms.of.len() }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 fn two_triangles() -> (usize, Vec<(usize, usize)>) {
241 (6, vec![(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3), (2, 3)])
242 }
243
244 #[test]
245 fn louvain_finds_the_two_clusters() {
246 let (n, edges) = two_triangles();
247 let c = louvain(n, &edges);
248 assert_eq!(c.count, 2, "two communities detected (modularity {})", c.modularity);
249 assert_eq!(c.of[0], c.of[1]);
251 assert_eq!(c.of[1], c.of[2]);
252 assert_eq!(c.of[3], c.of[4]);
253 assert_eq!(c.of[4], c.of[5]);
254 assert_ne!(c.of[0], c.of[3], "the bridge didn't merge the clusters");
255 assert!(c.modularity > 0.3, "a real community structure (Q={})", c.modularity);
256 }
257
258 #[test]
259 fn louvain_is_deterministic() {
260 let (n, edges) = two_triangles();
261 assert_eq!(louvain(n, &edges), louvain(n, &edges));
262 }
263
264 #[test]
265 fn edgeless_graph_is_all_singletons() {
266 let c = louvain(4, &[]);
267 assert_eq!(c.count, 4);
268 assert_eq!(c.modularity, 0.0);
269 }
270
271 #[test]
272 fn meta_graph_collapses_and_aggregates_the_bridge() {
273 let (n, edges) = two_triangles();
274 let c = louvain(n, &edges);
275 let positions = vec![[0.0, 0.0], [0.5, 1.0], [1.0, 0.0], [10.0, 0.0], [10.5, 1.0], [11.0, 0.0]];
277 let mg = meta_graph(&positions, &edges, &c);
278 assert_eq!(mg.metas.len(), 2, "one meta-node per community");
279 assert!(mg.metas.len() < n, "consolidation: {} meta-nodes < {n} nodes", mg.metas.len());
281 assert_eq!(mg.edges.len(), 1);
283 assert_eq!(mg.edges[0].2, 1.0, "one crossing edge aggregated");
284 let left = &mg.metas[c.of[0]];
286 assert!(left.pos[0] < 5.0 && (left.weight - 3.0).abs() < 1e-6, "3-member left cluster centroid");
287 }
288
289 #[test]
290 fn fracture_eases_from_collapsed_to_open_with_injected_clock() {
291 use std::f32::consts::PI;
292 let ease = |t: f32| 0.5 - 0.5 * (t * PI).cos();
294 assert_eq!(fracture_t(0.2, 0.5, 1.0, ease), 0.0, "below threshold: fully collapsed");
296 assert!((fracture_t(2.0, 0.5, 1.0, ease) - 1.0).abs() < 1e-5, "well past: fully fractured");
298 let mid = fracture_t(1.0, 0.5, 1.0, ease);
300 assert!(mid > 0.0 && mid < 1.0, "mid-zoom is mid-fracture ({mid})");
301 let a = fracture_t(0.7, 0.5, 1.0, ease);
302 let b = fracture_t(1.2, 0.5, 1.0, ease);
303 assert!(b > a, "zooming in advances the fracture ({a} → {b})");
304 }
305
306 #[test]
307 fn fractured_positions_and_visible_count_track_t() {
308 let (n, edges) = two_triangles();
309 let c = louvain(n, &edges);
310 let positions = vec![[0.0, 0.0], [0.5, 1.0], [1.0, 0.0], [10.0, 0.0], [10.5, 1.0], [11.0, 0.0]];
311 let mg = meta_graph(&positions, &edges, &c);
312
313 let collapsed = fractured_positions(&positions, &c, &mg, 0.0);
315 for i in 0..n {
316 assert_eq!(collapsed[i], mg.metas[c.of[i]].pos, "node {i} collapsed onto its meta-node");
317 }
318 assert_eq!(visible_count(&c, 0.0), 2, "low zoom shows 2 meta-nodes, not 6 nodes");
319
320 let open = fractured_positions(&positions, &c, &mg, 1.0);
322 for i in 0..n {
323 assert!((open[i][0] - positions[i][0]).abs() < 1e-5 && (open[i][1] - positions[i][1]).abs() < 1e-5);
324 }
325 assert_eq!(visible_count(&c, 1.0), 6);
326
327 let half = fractured_positions(&positions, &c, &mg, 0.5);
329 let i = 3usize;
330 let m = mg.metas[c.of[i]].pos;
331 assert!((half[i][0] - (m[0] + (positions[i][0] - m[0]) * 0.5)).abs() < 1e-5, "lerps halfway open");
332 }
333}