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