1use std::collections::{HashMap, VecDeque};
25
26const EPS: f64 = 1e-9;
29
30#[derive(Debug, Clone)]
36pub struct WeightedGraph {
37 n: usize,
38 adj: Vec<Vec<(usize, f64)>>,
39 loops: Vec<f64>,
40}
41
42impl WeightedGraph {
43 pub fn new(n: usize) -> Self {
45 Self {
46 n,
47 adj: vec![Vec::new(); n],
48 loops: vec![0.0; n],
49 }
50 }
51
52 pub fn node_count(&self) -> usize {
54 self.n
55 }
56
57 pub fn from_edges(n: usize, edges: impl IntoIterator<Item = (usize, usize, f64)>) -> Self {
62 let mut aggregated: HashMap<(usize, usize), f64> = HashMap::new();
63 for (u, v, w) in edges {
64 if u >= n || v >= n || !w.is_finite() || w <= 0.0 {
65 continue;
66 }
67 let key = if u <= v { (u, v) } else { (v, u) };
68 *aggregated.entry(key).or_insert(0.0) += w;
69 }
70
71 let mut graph = Self::new(n);
72 for ((u, v), w) in aggregated {
73 if u == v {
74 graph.loops[u] += w;
75 } else {
76 graph.adj[u].push((v, w));
77 graph.adj[v].push((u, w));
78 }
79 }
80 for neighbors in &mut graph.adj {
82 neighbors.sort_by(|a, b| a.0.cmp(&b.0));
83 }
84 graph
85 }
86
87 fn degree(&self, node: usize) -> f64 {
89 let incident: f64 = self.adj[node].iter().map(|(_, w)| *w).sum();
90 incident + 2.0 * self.loops[node]
91 }
92
93 fn weight_to(&self, node: usize, labels: &[i64], label: i64) -> f64 {
97 self.adj[node]
98 .iter()
99 .filter(|(nb, _)| labels[*nb] == label)
100 .map(|(_, w)| *w)
101 .sum()
102 }
103}
104
105#[derive(Debug, Clone)]
107struct Partition {
108 labels: Vec<i64>,
110 degree_sums: Vec<f64>,
112}
113
114impl Partition {
115 fn num_communities(&self) -> usize {
116 self.degree_sums.len()
117 }
118}
119
120fn compact_labels(raw: &[i64], degrees: &[f64]) -> Partition {
124 let mut remap: HashMap<i64, i64> = HashMap::new();
125 let mut labels = vec![-1i64; raw.len()];
126 let mut degree_sums = Vec::new();
127 for (node, &raw_label) in raw.iter().enumerate() {
128 let next = remap.len() as i64;
129 let label = *remap.entry(raw_label).or_insert(next);
130 labels[node] = label;
131 if label as usize >= degree_sums.len() {
132 degree_sums.push(0.0);
133 }
134 degree_sums[label as usize] += degrees[node];
135 }
136 Partition {
137 labels,
138 degree_sums,
139 }
140}
141
142struct Rng {
145 state: u64,
146}
147
148impl Rng {
149 fn new(seed: u64) -> Self {
150 Self { state: seed }
151 }
152
153 fn next_u64(&mut self) -> u64 {
154 self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
155 let mut z = self.state;
156 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
157 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
158 z ^ (z >> 31)
159 }
160
161 fn shuffle<T>(&mut self, items: &mut [T]) {
163 if items.len() < 2 {
164 return;
165 }
166 for i in (1..items.len()).rev() {
167 let j = (self.next_u64() % (i as u64 + 1)) as usize;
168 items.swap(i, j);
169 }
170 }
171}
172
173fn move_delta(
181 weight_to: f64,
182 weight_from: f64,
183 degree: f64,
184 sum_from_minus_node: f64,
185 sum_to: f64,
186 resolution: f64,
187 m: f64,
188) -> f64 {
189 2.0 * (weight_to - weight_from) + resolution * degree * (sum_from_minus_node - sum_to) / m
190}
191
192fn local_moving(
197 graph: &WeightedGraph,
198 initial: &[i64],
199 resolution: f64,
200 rng: &mut Rng,
201) -> Partition {
202 let degrees: Vec<f64> = (0..graph.n).map(|node| graph.degree(node)).collect();
203 let m2: f64 = degrees.iter().sum();
204
205 if m2 <= 0.0 {
207 return compact_labels(initial, °rees);
208 }
209 let m = m2 / 2.0;
210
211 let partition = compact_labels(initial, °rees);
212 let mut labels = partition.labels;
213 let mut degree_sums = partition.degree_sums;
214
215 let mut order: Vec<usize> = (0..graph.n).collect();
216 rng.shuffle(&mut order);
217
218 let mut queue: VecDeque<usize> = order.iter().copied().collect();
219 let mut in_queue = vec![true; graph.n];
220
221 while let Some(node) = queue.pop_front() {
222 in_queue[node] = false;
223
224 let current = labels[node];
225 let node_degree = degrees[node];
226 let sum_from_without_node = degree_sums[current as usize] - node_degree;
227 let weight_current = graph.weight_to(node, &labels, current);
228
229 let mut candidate_weight: HashMap<i64, f64> = HashMap::new();
231 for &(nb, w) in &graph.adj[node] {
232 let label = labels[nb];
233 *candidate_weight.entry(label).or_insert(0.0) += w;
234 }
235
236 let mut best_label = current;
238 let mut best_delta = 0.0_f64;
239 for (&label, &weight_to_label) in &candidate_weight {
240 if label == current {
241 continue;
242 }
243 let delta = move_delta(
244 weight_to_label,
245 weight_current,
246 node_degree,
247 sum_from_without_node,
248 degree_sums[label as usize],
249 resolution,
250 m,
251 );
252 if delta > best_delta + EPS
253 || (delta > EPS && (delta - best_delta).abs() <= EPS && label < best_label)
254 {
255 best_delta = delta;
256 best_label = label;
257 }
258 }
259
260 if best_label != current {
261 degree_sums[current as usize] -= node_degree;
262 degree_sums[best_label as usize] += node_degree;
263 labels[node] = best_label;
264
265 for &(nb, _) in &graph.adj[node] {
267 if labels[nb] != best_label && !in_queue[nb] {
268 in_queue[nb] = true;
269 queue.push_back(nb);
270 }
271 }
272 }
273 }
274
275 Partition {
276 labels,
277 degree_sums,
278 }
279}
280
281fn refine(
289 graph: &WeightedGraph,
290 communities: &Partition,
291 resolution: f64,
292 rng: &mut Rng,
293) -> Partition {
294 let degrees: Vec<f64> = (0..graph.n).map(|node| graph.degree(node)).collect();
295 let m2: f64 = degrees.iter().sum();
296 let m = m2 / 2.0;
297
298 let mut subset = vec![-1i64; graph.n];
299 let mut subset_degree_sums: Vec<f64> = Vec::new();
300 let mut next_subset = 0i64;
301
302 let mut community_nodes: HashMap<i64, Vec<usize>> = HashMap::new();
303 for (node, &label) in communities.labels.iter().enumerate() {
304 community_nodes.entry(label).or_default().push(node);
305 }
306 let mut community_order: Vec<i64> = community_nodes.keys().copied().collect();
307 community_order.sort_unstable();
308
309 for community in community_order {
310 let mut nodes = community_nodes.remove(&community).unwrap_or_default();
311 rng.shuffle(&mut nodes);
312
313 for node in nodes {
314 let mut candidate_weight: HashMap<i64, f64> = HashMap::new();
316 for &(nb, w) in &graph.adj[node] {
317 let s = subset[nb];
318 if s >= 0 && communities.labels[nb] == community {
319 *candidate_weight.entry(s).or_insert(0.0) += w;
320 }
321 }
322
323 let mut best_subset = -1i64;
326 let mut best_delta = 0.0_f64;
327 for (&s, &weight) in &candidate_weight {
328 let delta =
329 2.0 * weight - resolution * degrees[node] * subset_degree_sums[s as usize] / m;
330 if delta < -EPS {
331 continue;
332 }
333 if best_subset < 0
334 || delta > best_delta + EPS
335 || ((delta - best_delta).abs() <= EPS && s < best_subset)
336 {
337 best_delta = delta;
338 best_subset = s;
339 }
340 }
341
342 if best_subset < 0 {
343 best_subset = next_subset;
344 next_subset += 1;
345 subset_degree_sums.push(0.0);
346 }
347 subset[node] = best_subset;
348 subset_degree_sums[best_subset as usize] += degrees[node];
349 }
350 }
351
352 compact_labels(&subset, °rees)
353}
354
355struct Aggregated {
358 graph: WeightedGraph,
359 members: Vec<Vec<usize>>,
360}
361
362fn aggregate(graph: &WeightedGraph, refined: &Partition, members: &[Vec<usize>]) -> Aggregated {
365 let k = refined.num_communities();
366
367 let mut edges: HashMap<(usize, usize), f64> = HashMap::new();
368 for node in 0..graph.n {
369 let subset = refined.labels[node] as usize;
370 for &(nb, w) in &graph.adj[node] {
371 if nb > node {
372 let other = refined.labels[nb] as usize;
373 let key = if subset <= other {
374 (subset, other)
375 } else {
376 (other, subset)
377 };
378 *edges.entry(key).or_insert(0.0) += w;
379 }
380 }
381 if graph.loops[node] > 0.0 {
382 *edges.entry((subset, subset)).or_insert(0.0) += graph.loops[node];
383 }
384 }
385
386 let mut aggregated = WeightedGraph::new(k);
387 for ((u, v), w) in edges {
388 if u == v {
389 aggregated.loops[u] += w;
390 } else {
391 aggregated.adj[u].push((v, w));
392 aggregated.adj[v].push((u, w));
393 }
394 }
395 for neighbors in &mut aggregated.adj {
396 neighbors.sort_by(|a, b| a.0.cmp(&b.0));
397 }
398
399 let mut aggregated_members = vec![Vec::new(); k];
400 for (node, original) in members.iter().enumerate() {
401 aggregated_members[refined.labels[node] as usize].extend_from_slice(original);
402 }
403
404 Aggregated {
405 graph: aggregated,
406 members: aggregated_members,
407 }
408}
409
410pub fn leiden_modularity(graph: &WeightedGraph, resolution: f64, seed: u64) -> Vec<usize> {
420 let n = graph.n;
421 if n == 0 {
422 return Vec::new();
423 }
424 if n == 1 {
425 return vec![0];
426 }
427
428 let mut rng = Rng::new(seed);
429 let members: Vec<Vec<usize>> = (0..n).map(|node| vec![node]).collect();
430
431 let singleton: Vec<i64> = (0..n as i64).collect();
433 let mut partition = local_moving(graph, &singleton, resolution, &mut rng);
434 let mut current_graph = graph.clone();
435 let mut current_members = members;
436
437 loop {
438 if partition.num_communities() == current_graph.n {
440 break;
441 }
442
443 let refined = refine(¤t_graph, &partition, resolution, &mut rng);
444
445 if refined.num_communities() == partition.num_communities() {
448 break;
449 }
450
451 let aggregated = aggregate(¤t_graph, &refined, ¤t_members);
452
453 let mut initial = vec![-1i64; aggregated.graph.n];
456 for node in 0..current_graph.n {
457 initial[refined.labels[node] as usize] = partition.labels[node];
458 }
459
460 partition = local_moving(&aggregated.graph, &initial, resolution, &mut rng);
461 current_graph = aggregated.graph;
462 current_members = aggregated.members;
463 }
464
465 let mut result = vec![0usize; n];
467 for (aggregate_node, originals) in current_members.iter().enumerate() {
468 for &original in originals {
469 result[original] = partition.labels[aggregate_node] as usize;
470 }
471 }
472
473 let degrees: Vec<f64> = (0..n).map(|node| graph.degree(node)).collect();
475 let compact = compact_labels(
476 &result.iter().map(|&x| x as i64).collect::<Vec<_>>(),
477 °rees,
478 );
479 compact.labels.iter().map(|&x| x as usize).collect()
480}
481
482#[cfg(test)]
485pub(crate) fn is_connected_induced(graph: &WeightedGraph, nodes: &[usize]) -> bool {
486 if nodes.is_empty() {
487 return true;
488 }
489 let in_set: std::collections::HashSet<usize> = nodes.iter().copied().collect();
490 let mut seen = std::collections::HashSet::new();
491 let mut stack = vec![nodes[0]];
492 seen.insert(nodes[0]);
493 while let Some(node) = stack.pop() {
494 for &(nb, _) in &graph.adj[node] {
495 if in_set.contains(&nb) && seen.insert(nb) {
496 stack.push(nb);
497 }
498 }
499 }
500 seen.len() == nodes.len()
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 fn graph_from(n: usize, pairs: &[(usize, usize)]) -> WeightedGraph {
509 let edges = pairs.iter().map(|&(u, v)| (u, v, 1.0)).collect::<Vec<_>>();
510 WeightedGraph::from_edges(n, edges)
511 }
512
513 fn push_clique(pairs: &mut Vec<(usize, usize)>, offset: usize, k: usize) {
515 for i in 0..k {
516 for j in (i + 1)..k {
517 pairs.push((offset + i, offset + j));
518 }
519 }
520 }
521
522 fn groups(labels: &[usize]) -> Vec<Vec<usize>> {
523 let k = labels.iter().copied().max().map(|m| m + 1).unwrap_or(0);
524 let mut groups = vec![Vec::new(); k];
525 for (node, &label) in labels.iter().enumerate() {
526 groups[label].push(node);
527 }
528 groups.retain(|g| !g.is_empty());
529 groups
530 }
531
532 #[test]
533 fn empty_and_single_node_graphs() {
534 let g = WeightedGraph::new(0);
535 assert!(leiden_modularity(&g, 1.0, 1).is_empty());
536 let g = WeightedGraph::new(1);
537 assert_eq!(leiden_modularity(&g, 1.0, 1), vec![0]);
538 }
539
540 #[test]
541 fn connected_pair_is_one_community() {
542 let g = graph_from(2, &[(0, 1)]);
543 let labels = leiden_modularity(&g, 1.0, 1);
544 assert_eq!(labels[0], labels[1]);
545 }
546
547 #[test]
548 fn two_disconnected_triangles_stay_separate() {
549 let mut pairs = Vec::new();
550 push_clique(&mut pairs, 0, 3);
551 push_clique(&mut pairs, 3, 3);
552 let g = graph_from(6, &pairs);
553 let labels = leiden_modularity(&g, 1.0, 1);
554 assert_ne!(labels[0], labels[3]);
555 assert_eq!(labels[0], labels[1]);
556 assert_eq!(labels[1], labels[2]);
557 assert_eq!(labels[3], labels[4]);
558 assert_eq!(labels[4], labels[5]);
559 }
560
561 #[test]
562 fn weak_bridge_does_not_merge_dense_cliques() {
563 let mut pairs = Vec::new();
565 push_clique(&mut pairs, 0, 4);
566 push_clique(&mut pairs, 4, 4);
567 pairs.push((3, 4));
568 let g = graph_from(8, &pairs);
569 let labels = leiden_modularity(&g, 1.0, 7);
570 let groups = groups(&labels);
571 assert_eq!(groups.len(), 2, "expected two communities, got {groups:?}");
572 for group in &groups {
573 assert_eq!(group.len(), 4);
574 assert!(is_connected_induced(&g, group));
575 }
576 }
577
578 #[test]
579 fn hierarchy_fixture_flat_partition_is_four_groups() {
580 let mut pairs = Vec::new();
587 push_clique(&mut pairs, 0, 3); push_clique(&mut pairs, 3, 3); push_clique(&mut pairs, 6, 8); push_clique(&mut pairs, 14, 8); pairs.push((2, 3));
592 pairs.push((13, 14));
593 let g = graph_from(22, &pairs);
594
595 let labels = leiden_modularity(&g, 1.0, 7);
596 let level0 = groups(&labels);
597 assert_eq!(level0.len(), 4, "level 0: {level0:?}");
598 assert_ne!(labels[0], labels[3]);
600 for group in &level0 {
601 assert!(is_connected_induced(&g, group));
602 }
603 }
604
605 #[test]
606 fn heavy_bridge_edge_merges_two_pairs() {
607 let weak = WeightedGraph::from_edges(4, vec![(0, 1, 1.0), (2, 3, 1.0)]);
609 assert_eq!(groups(&leiden_modularity(&weak, 1.0, 3)).len(), 2);
610
611 let strong = WeightedGraph::from_edges(4, vec![(0, 1, 1.0), (2, 3, 1.0), (1, 2, 10.0)]);
614 assert_eq!(groups(&leiden_modularity(&strong, 1.0, 3)).len(), 1);
615 }
616
617 #[test]
618 fn result_is_deterministic_for_a_seed() {
619 let mut pairs = Vec::new();
621 for c in 0..4 {
622 push_clique(&mut pairs, c * 8, 8);
623 }
624 pairs.push((6, 10));
625 pairs.push((20, 26));
626 pairs.push((1, 17));
627 let g = graph_from(32, &pairs);
628
629 let a = leiden_modularity(&g, 1.0, 123_456);
630 let b = leiden_modularity(&g, 1.0, 123_456);
631 assert_eq!(a, b);
632
633 for group in groups(&a) {
636 assert!(
637 is_connected_induced(&g, &group),
638 "disconnected community {group:?}"
639 );
640 }
641 }
642
643 #[test]
644 fn isolated_nodes_remain_singletons() {
645 let g = graph_from(5, &[(0, 1), (1, 2)]);
646 let labels = leiden_modularity(&g, 1.0, 1);
647 let groups = groups(&labels);
648 assert_eq!(groups.len(), 3);
650 assert!(groups.iter().any(|g| g.len() == 3));
651 assert_eq!(groups.iter().filter(|g| g.len() == 1).count(), 2);
652 }
653
654 #[test]
655 fn malformed_edges_are_ignored() {
656 let g = WeightedGraph::from_edges(
657 3,
658 vec![
659 (0, 1, 1.0),
660 (0, 9, 1.0), (1, 2, -1.0), (2, 2, 2.0), (0, 2, f64::NAN),
664 ],
665 );
666 assert_eq!(g.node_count(), 3);
667 let labels = leiden_modularity(&g, 1.0, 1);
668 assert_eq!(labels[0], labels[1]);
670 assert_ne!(labels[0], labels[2]);
671 }
672
673 #[test]
674 fn rng_shuffle_is_deterministic() {
675 let mut a = Rng::new(99);
676 let mut va: Vec<usize> = (0..20).collect();
677 a.shuffle(&mut va);
678 let mut b = Rng::new(99);
679 let mut vb: Vec<usize> = (0..20).collect();
680 b.shuffle(&mut vb);
681 assert_eq!(va, vb);
682 assert_ne!(va, (0..20).collect::<Vec<_>>());
683 }
684}