1#![allow(
33 clippy::cast_possible_truncation,
34 clippy::cast_sign_loss,
35 clippy::many_single_char_names
36)]
37
38use crate::core::graph::EdgeId;
39use crate::core::{Graph, IgraphError, IgraphResult};
40
41const MAX_LEVELS: usize = 64;
42const MAX_PASSES_PER_LEVEL: usize = 256;
43
44#[derive(Debug, Clone)]
53pub struct LouvainResult {
54 pub membership: Vec<u32>,
55 pub modularity: f64,
56 pub levels: Vec<Vec<u32>>,
57 pub modularities: Vec<f64>,
58}
59
60pub fn louvain(graph: &Graph) -> IgraphResult<LouvainResult> {
83 louvain_with_options(graph, None, 1.0, 0)
84}
85
86pub fn louvain_weighted(graph: &Graph, weights: &[f64]) -> IgraphResult<LouvainResult> {
110 louvain_with_options(graph, Some(weights), 1.0, 0)
111}
112
113pub fn louvain_with_options(
143 graph: &Graph,
144 weights: Option<&[f64]>,
145 resolution: f64,
146 seed: u64,
147) -> IgraphResult<LouvainResult> {
148 if graph.is_directed() {
149 return Err(IgraphError::Unsupported(
150 "louvain requires an undirected graph",
151 ));
152 }
153 if !resolution.is_finite() || resolution < 0.0 {
154 return Err(IgraphError::InvalidArgument(
155 "resolution parameter must be non-negative and finite".to_string(),
156 ));
157 }
158 validate_weights(graph, weights)?;
159
160 let vcount = graph.vcount() as usize;
161 if vcount == 0 {
162 return Ok(LouvainResult {
163 membership: Vec::new(),
164 modularity: 0.0,
165 levels: Vec::new(),
166 modularities: Vec::new(),
167 });
168 }
169
170 let mut state = build_initial_state(graph, weights)?;
172
173 let mut original_to_current: Vec<u32> = (0..vcount).map(|i| i as u32).collect();
176
177 let mut levels: Vec<Vec<u32>> = Vec::new();
178 let mut modularities: Vec<f64> = Vec::new();
179 let mut rng = SplitMix64::new(seed);
180
181 for _level in 0..MAX_LEVELS {
182 let initial_n = state.n;
183 let prev_q = current_modularity(&state, resolution);
184
185 let (q, changed_any) = local_moving_loop(&mut state, resolution, &mut rng);
188
189 let k = reindex_membership(&mut state.membership);
191
192 for slot in &mut original_to_current {
194 *slot = state.membership[*slot as usize];
195 }
196
197 levels.push(original_to_current.clone());
199 modularities.push(q);
200
201 if !changed_any || k as usize == initial_n || q <= prev_q + Q_EPS {
203 break;
204 }
205
206 state = aggregate(&state, k as usize);
208 }
209
210 let membership = original_to_current;
212 let modularity = modularities.last().copied().unwrap_or(0.0);
213
214 Ok(LouvainResult {
215 membership,
216 modularity,
217 levels,
218 modularities,
219 })
220}
221
222const Q_EPS: f64 = 1e-10;
225
226struct WorkingState {
237 n: usize,
238 adj: Vec<Vec<(u32, f64)>>,
242 loop_weight: Vec<f64>,
244 vertex_weight: Vec<f64>,
248 membership: Vec<u32>,
250 weight_all: Vec<f64>,
253 weight_inside: Vec<f64>,
256 size: Vec<u32>,
258 weight_sum: f64,
260}
261
262fn build_initial_state(graph: &Graph, weights: Option<&[f64]>) -> IgraphResult<WorkingState> {
263 let n = graph.vcount() as usize;
264 let m = graph.ecount();
265 let m_u32 = u32::try_from(m)
266 .map_err(|_| IgraphError::InvalidArgument("edge count exceeds u32::MAX".into()))?;
267 let mut edges: Vec<(u32, u32, f64)> = Vec::with_capacity(m);
268 for e in 0..m_u32 {
269 let (u, v) = graph.edge(e as EdgeId)?;
270 let w = weights.map_or(1.0, |ws| ws[e as usize]);
271 let (a, b) = if u <= v { (u, v) } else { (v, u) };
272 edges.push((a, b, w));
273 }
274 Ok(init_from_edges(n, &edges))
275}
276
277fn init_from_edges(n: usize, edges: &[(u32, u32, f64)]) -> WorkingState {
283 let mut adj: Vec<Vec<(u32, f64)>> = vec![Vec::new(); n];
284 let mut loop_weight = vec![0.0_f64; n];
285 let mut vertex_weight = vec![0.0_f64; n];
286 let mut total: f64 = 0.0;
287 for &(u, v, w) in edges {
288 total += w;
289 let uu = u as usize;
290 let vv = v as usize;
291 if uu == vv {
292 loop_weight[uu] += 2.0 * w;
293 vertex_weight[uu] += 2.0 * w;
294 } else {
295 adj[uu].push((v, w));
296 adj[vv].push((u, w));
297 vertex_weight[uu] += w;
298 vertex_weight[vv] += w;
299 }
300 }
301
302 let membership: Vec<u32> = (0..n as u32).collect();
303 let weight_all = vertex_weight.clone();
304 let weight_inside = loop_weight.clone();
305 let size = vec![1_u32; n];
306 let weight_sum = 2.0 * total;
307
308 WorkingState {
309 n,
310 adj,
311 loop_weight,
312 vertex_weight,
313 membership,
314 weight_all,
315 weight_inside,
316 size,
317 weight_sum,
318 }
319}
320
321fn current_modularity(state: &WorkingState, resolution: f64) -> f64 {
324 if state.weight_sum <= 0.0 {
325 return 0.0;
326 }
327 let m = state.weight_sum;
328 let mut q = 0.0_f64;
329 for c in 0..state.n {
330 if state.size[c] > 0 {
331 let kall = state.weight_all[c];
332 q += (state.weight_inside[c] - resolution * kall * kall / m) / m;
333 }
334 }
335 q
336}
337
338fn local_moving_loop(
341 state: &mut WorkingState,
342 resolution: f64,
343 rng: &mut SplitMix64,
344) -> (f64, bool) {
345 let mut any_changed = false;
346 let mut q = current_modularity(state, resolution);
347 let mut node_order: Vec<usize> = (0..state.n).collect();
348 if state.n > 1 {
349 shuffle_in_place(&mut node_order, rng);
350 }
351
352 for _ in 0..MAX_PASSES_PER_LEVEL {
353 let pass_q = q;
354 let mut changed = false;
355
356 if state.n > 1 {
359 let i1 = rng.gen_index(state.n);
360 let i2 = rng.gen_index(state.n);
361 node_order.swap(i1, i2);
362 }
363
364 for &ni in &node_order {
365 local_move_vertex(state, ni, resolution, &mut changed);
366 }
367
368 q = current_modularity(state, resolution);
369 if changed && q > pass_q + Q_EPS {
370 any_changed = true;
371 continue;
372 }
373 break;
374 }
375
376 (q, any_changed)
377}
378
379fn local_move_vertex(state: &mut WorkingState, ni: usize, resolution: f64, changed: &mut bool) {
384 let weight_all_v = state.vertex_weight[ni];
385 let weight_loop_v = state.loop_weight[ni];
386 let old_id = state.membership[ni] as usize;
387
388 let mut neigh_communities: Vec<u32> = Vec::with_capacity(state.adj[ni].len());
392 let mut neigh_weights: Vec<f64> = Vec::with_capacity(state.adj[ni].len());
393 let mut weight_inside_old = 0.0_f64; for &(nei, w) in &state.adj[ni] {
396 let c = state.membership[nei as usize];
397 if c as usize == old_id {
398 weight_inside_old += w;
399 }
400 if let Some(idx) = neigh_communities.iter().position(|&x| x == c) {
403 neigh_weights[idx] += w;
404 } else {
405 neigh_communities.push(c);
406 neigh_weights.push(w);
407 }
408 }
409
410 state.size[old_id] -= 1;
412 state.weight_all[old_id] -= weight_all_v;
413 state.weight_inside[old_id] -= 2.0 * weight_inside_old + weight_loop_v;
414
415 let mut best_id = old_id;
417 let mut best_gain = 0.0_f64;
418 let mut best_weight = weight_inside_old;
419 for (idx, &c) in neigh_communities.iter().enumerate() {
420 let w_to_c = neigh_weights[idx];
421 let kall_c = state.weight_all[c as usize];
424 let gain = w_to_c - resolution * kall_c * weight_all_v / state.weight_sum;
426 if gain > best_gain {
427 best_gain = gain;
428 best_id = c as usize;
429 best_weight = w_to_c;
430 }
431 }
432
433 state.membership[ni] = best_id as u32;
435 state.size[best_id] += 1;
436 state.weight_all[best_id] += weight_all_v;
437 state.weight_inside[best_id] += 2.0 * best_weight + weight_loop_v;
438
439 if best_id != old_id {
440 *changed = true;
441 }
442}
443
444fn reindex_membership(membership: &mut [u32]) -> u32 {
447 if membership.is_empty() {
448 return 0;
449 }
450 let max = *membership.iter().max().unwrap_or(&0);
451 let mut remap: Vec<i64> = vec![-1; max as usize + 1];
452 let mut next: u32 = 0;
453 for slot in membership.iter_mut() {
454 let old = *slot as usize;
455 if remap[old] < 0 {
456 remap[old] = i64::from(next);
457 next += 1;
458 }
459 *slot = remap[old] as u32;
460 }
461 next
462}
463
464fn aggregate(state: &WorkingState, k: usize) -> WorkingState {
470 let mut loop_raw: Vec<f64> = vec![0.0_f64; k];
473 let mut inter: Vec<Vec<(u32, f64)>> = vec![Vec::new(); k];
476
477 for u in 0..state.n {
481 let raw = state.loop_weight[u] * 0.5;
482 if raw > 0.0 {
483 let mu = state.membership[u] as usize;
484 loop_raw[mu] += raw;
485 }
486 }
487
488 for u in 0..state.n {
491 for &(v, w) in &state.adj[u] {
492 let v_us = v as usize;
493 if u >= v_us {
494 continue;
495 }
496 let mu = state.membership[u] as usize;
497 let mv = state.membership[v_us] as usize;
498 if mu == mv {
499 loop_raw[mu] += w;
500 } else {
501 let (a, b) = if mu < mv { (mu, mv) } else { (mv, mu) };
502 push_or_merge(&mut inter[a], b as u32, w);
503 }
504 }
505 }
506
507 let mut edges: Vec<(u32, u32, f64)> = Vec::new();
510 for u in 0..k {
511 if loop_raw[u] > 0.0 {
512 edges.push((u as u32, u as u32, loop_raw[u]));
513 }
514 for &(v, w) in &inter[u] {
515 edges.push((u as u32, v, w));
516 }
517 }
518
519 init_from_edges(k, &edges)
520}
521
522fn push_or_merge(list: &mut Vec<(u32, f64)>, neighbour: u32, weight: f64) {
523 for slot in list.iter_mut() {
524 if slot.0 == neighbour {
525 slot.1 += weight;
526 return;
527 }
528 }
529 list.push((neighbour, weight));
530}
531
532fn validate_weights(graph: &Graph, weights: Option<&[f64]>) -> IgraphResult<()> {
535 let Some(w) = weights else {
536 return Ok(());
537 };
538 let m = graph.ecount();
539 if w.len() != m {
540 return Err(IgraphError::InvalidArgument(format!(
541 "weights vector size ({}) differs from edge count ({})",
542 w.len(),
543 m
544 )));
545 }
546 for (e, &v) in w.iter().enumerate() {
547 if v.is_nan() {
548 return Err(IgraphError::InvalidArgument(format!(
549 "weight at edge {e} is NaN"
550 )));
551 }
552 if v < 0.0 {
553 return Err(IgraphError::InvalidArgument(format!(
554 "weight at edge {e} is negative ({v}); louvain weights must be non-negative"
555 )));
556 }
557 }
558 Ok(())
559}
560
561struct SplitMix64(u64);
564
565impl SplitMix64 {
566 fn new(seed: u64) -> Self {
567 Self(seed)
568 }
569 fn next_u64(&mut self) -> u64 {
570 self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
571 let mut z = self.0;
572 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
573 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
574 z ^ (z >> 31)
575 }
576 fn gen_index(&mut self, bound: usize) -> usize {
577 debug_assert!(bound > 0);
578 let r = self.next_u64() % (bound as u64);
579 usize::try_from(r).expect("bound fits in usize by construction")
580 }
581}
582
583fn shuffle_in_place<T>(slice: &mut [T], rng: &mut SplitMix64) {
584 let len = slice.len();
586 for i in (1..len).rev() {
587 let j = rng.gen_index(i + 1);
588 slice.swap(i, j);
589 }
590}
591
592#[cfg(test)]
593#[allow(clippy::float_cmp)]
594mod tests {
595 use super::*;
596
597 fn add_undirected_edges(g: &mut Graph, edges: &[(u32, u32)]) {
598 for &(u, v) in edges {
599 g.add_edge(u, v).unwrap();
600 }
601 }
602
603 #[test]
604 fn empty_graph_returns_empty_membership() {
605 let g = Graph::with_vertices(0);
606 let r = louvain(&g).unwrap();
607 assert_eq!(r.membership.len(), 0);
608 assert_eq!(r.modularity, 0.0);
609 assert!(r.levels.is_empty());
610 }
611
612 #[test]
613 fn isolated_vertices_each_their_own_community() {
614 let g = Graph::with_vertices(4);
615 let r = louvain(&g).unwrap();
616 assert_eq!(r.membership.len(), 4);
619 for &c in &r.membership {
621 assert!(c < 4);
622 }
623 }
624
625 #[test]
626 fn single_edge_two_communities_or_merged() {
627 let mut g = Graph::with_vertices(2);
628 g.add_edge(0, 1).unwrap();
629 let r = louvain(&g).unwrap();
630 assert_eq!(r.membership[0], r.membership[1]);
633 }
634
635 #[test]
636 fn two_triangles_bridge_finds_two_communities() {
637 let mut g = Graph::with_vertices(6);
638 add_undirected_edges(
639 &mut g,
640 &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
641 );
642 let r = louvain(&g).unwrap();
643 assert_eq!(r.membership[0], r.membership[1]);
646 assert_eq!(r.membership[1], r.membership[2]);
647 assert_eq!(r.membership[3], r.membership[4]);
648 assert_eq!(r.membership[4], r.membership[5]);
649 assert_ne!(r.membership[0], r.membership[3]);
650 assert!(r.modularity > 0.35);
651 }
652
653 #[test]
654 fn complete_k5_gives_one_or_two_communities() {
655 let mut g = Graph::with_vertices(5);
656 for u in 0..5u32 {
657 for v in (u + 1)..5 {
658 g.add_edge(u, v).unwrap();
659 }
660 }
661 let r = louvain(&g).unwrap();
662 let k: std::collections::BTreeSet<_> = r.membership.iter().copied().collect();
666 assert!(k.len() <= 2, "K5 should yield ≤ 2 communities");
667 assert!(r.modularity >= -1e-9);
668 }
669
670 #[test]
671 fn disconnected_components_separate_communities() {
672 let mut g = Graph::with_vertices(8);
673 for u in 0..4u32 {
675 for v in (u + 1)..4 {
676 g.add_edge(u, v).unwrap();
677 }
678 }
679 for u in 4..8u32 {
680 for v in (u + 1)..8 {
681 g.add_edge(u, v).unwrap();
682 }
683 }
684 let r = louvain(&g).unwrap();
685 assert_eq!(r.membership[0], r.membership[1]);
686 assert_eq!(r.membership[4], r.membership[5]);
687 assert_ne!(r.membership[0], r.membership[4]);
688 assert!(r.modularity > 0.4);
689 }
690
691 #[test]
692 fn directed_graph_returns_unsupported() {
693 let mut g = Graph::new(3, true).unwrap();
694 g.add_edge(0, 1).unwrap();
695 assert!(louvain(&g).is_err());
696 }
697
698 #[test]
699 fn weights_length_mismatch_errors() {
700 let mut g = Graph::with_vertices(3);
701 g.add_edge(0, 1).unwrap();
702 g.add_edge(1, 2).unwrap();
703 let r = louvain_weighted(&g, &[1.0]);
704 assert!(r.is_err());
705 }
706
707 #[test]
708 fn weights_negative_errors() {
709 let mut g = Graph::with_vertices(3);
710 g.add_edge(0, 1).unwrap();
711 g.add_edge(1, 2).unwrap();
712 let r = louvain_weighted(&g, &[1.0, -0.5]);
713 assert!(r.is_err());
714 }
715
716 #[test]
717 fn weights_nan_errors() {
718 let mut g = Graph::with_vertices(3);
719 g.add_edge(0, 1).unwrap();
720 g.add_edge(1, 2).unwrap();
721 let r = louvain_weighted(&g, &[1.0, f64::NAN]);
722 assert!(r.is_err());
723 }
724
725 #[test]
726 fn negative_resolution_errors() {
727 let mut g = Graph::with_vertices(3);
728 g.add_edge(0, 1).unwrap();
729 assert!(louvain_with_options(&g, None, -0.1, 0).is_err());
730 }
731
732 #[test]
733 fn weighted_unit_matches_unweighted() {
734 let mut g = Graph::with_vertices(6);
735 add_undirected_edges(
736 &mut g,
737 &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
738 );
739 let ones = vec![1.0; g.ecount()];
740 let a = louvain(&g).unwrap();
741 let b = louvain_weighted(&g, &ones).unwrap();
742 assert!((a.modularity - b.modularity).abs() < 1e-9);
744 for i in 0..6 {
748 for j in 0..6 {
749 let ai = a.membership[i] == a.membership[j];
750 let bi = b.membership[i] == b.membership[j];
751 assert_eq!(ai, bi, "partition mismatch at ({i},{j})");
752 }
753 }
754 }
755
756 #[test]
757 fn modularity_matches_external_recompute() {
758 let mut g = Graph::with_vertices(6);
761 add_undirected_edges(
762 &mut g,
763 &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
764 );
765 let r = louvain(&g).unwrap();
766 let q_recomputed =
767 crate::algorithms::community::modularity::modularity(&g, &r.membership, 1.0)
768 .unwrap()
769 .unwrap();
770 assert!(
771 (r.modularity - q_recomputed).abs() < 1e-9,
772 "louvain reports {} but modularity() reports {}",
773 r.modularity,
774 q_recomputed
775 );
776 }
777
778 #[test]
779 fn resolution_zero_gives_single_community() {
780 let mut g = Graph::with_vertices(6);
783 add_undirected_edges(
784 &mut g,
785 &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
786 );
787 let r = louvain_with_options(&g, None, 0.0, 0).unwrap();
788 let k: std::collections::BTreeSet<_> = r.membership.iter().copied().collect();
789 assert_eq!(k.len(), 1);
790 }
791
792 #[test]
793 fn resolution_high_gives_many_communities() {
794 let mut g = Graph::with_vertices(6);
795 add_undirected_edges(
796 &mut g,
797 &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
798 );
799 let r = louvain_with_options(&g, None, 10.0, 0).unwrap();
800 let k: std::collections::BTreeSet<_> = r.membership.iter().copied().collect();
801 assert!(
802 k.len() >= 3,
803 "with γ=10 we expect more granular communities"
804 );
805 }
806
807 #[test]
808 fn deterministic_with_seed() {
809 let mut g = Graph::with_vertices(10);
810 for u in 0..10u32 {
811 for v in (u + 1)..10 {
812 if (u + v) % 3 != 0 {
813 g.add_edge(u, v).unwrap();
814 }
815 }
816 }
817 let a = louvain_with_options(&g, None, 1.0, 42).unwrap();
818 let b = louvain_with_options(&g, None, 1.0, 42).unwrap();
819 assert_eq!(a.membership, b.membership);
820 assert!((a.modularity - b.modularity).abs() < 1e-12);
821 }
822
823 #[test]
824 fn levels_recorded() {
825 let mut g = Graph::with_vertices(6);
826 add_undirected_edges(
827 &mut g,
828 &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
829 );
830 let r = louvain(&g).unwrap();
831 assert!(!r.levels.is_empty());
832 assert_eq!(r.levels.len(), r.modularities.len());
833 assert_eq!(*r.levels.last().unwrap(), r.membership);
835 }
836
837 #[test]
838 fn karate_club_finds_known_partition() {
839 let edges: &[(u32, u32)] = &[
844 (0, 1),
845 (0, 2),
846 (0, 3),
847 (0, 4),
848 (0, 5),
849 (0, 6),
850 (0, 7),
851 (0, 8),
852 (0, 10),
853 (0, 11),
854 (0, 12),
855 (0, 13),
856 (0, 17),
857 (0, 19),
858 (0, 21),
859 (0, 31),
860 (1, 2),
861 (1, 3),
862 (1, 7),
863 (1, 13),
864 (1, 17),
865 (1, 19),
866 (1, 21),
867 (1, 30),
868 (2, 3),
869 (2, 7),
870 (2, 8),
871 (2, 9),
872 (2, 13),
873 (2, 27),
874 (2, 28),
875 (2, 32),
876 (3, 7),
877 (3, 12),
878 (3, 13),
879 (4, 6),
880 (4, 10),
881 (5, 6),
882 (5, 10),
883 (5, 16),
884 (6, 16),
885 (8, 30),
886 (8, 32),
887 (8, 33),
888 (9, 33),
889 (13, 33),
890 (14, 32),
891 (14, 33),
892 (15, 32),
893 (15, 33),
894 (18, 32),
895 (18, 33),
896 (19, 33),
897 (20, 32),
898 (20, 33),
899 (22, 32),
900 (22, 33),
901 (23, 25),
902 (23, 27),
903 (23, 29),
904 (23, 32),
905 (23, 33),
906 (24, 25),
907 (24, 27),
908 (24, 31),
909 (25, 31),
910 (26, 29),
911 (26, 33),
912 (27, 33),
913 (28, 31),
914 (28, 33),
915 (29, 32),
916 (29, 33),
917 (30, 32),
918 (30, 33),
919 (31, 32),
920 (31, 33),
921 (32, 33),
922 ];
923 let mut g = Graph::with_vertices(34);
924 add_undirected_edges(&mut g, edges);
925 let r = louvain(&g).unwrap();
926 assert!(
927 r.modularity > 0.39,
928 "karate club Louvain Q should exceed 0.39, got {}",
929 r.modularity
930 );
931 let k: std::collections::BTreeSet<_> = r.membership.iter().copied().collect();
933 assert!(k.len() >= 2 && k.len() <= 6);
934 }
935}