rust_igraph/algorithms/community/
fluid_communities.rs1#![allow(
42 clippy::cast_possible_truncation,
43 clippy::cast_possible_wrap,
44 clippy::cast_precision_loss,
45 clippy::cast_sign_loss,
46 clippy::float_cmp,
47 clippy::items_after_statements,
48 clippy::many_single_char_names,
49 clippy::needless_range_loop,
50 clippy::too_many_lines
51)]
52
53use crate::core::{Graph, IgraphError, IgraphResult};
54
55#[derive(Debug, Clone)]
57pub struct FluidOptions {
58 pub seed: u64,
62 pub max_iterations: u32,
68}
69
70pub const FLUID_DEFAULT_MAX_ITERATIONS: u32 = 1000;
73
74impl Default for FluidOptions {
75 fn default() -> Self {
76 Self {
77 seed: 0,
78 max_iterations: FLUID_DEFAULT_MAX_ITERATIONS,
79 }
80 }
81}
82
83#[derive(Debug, Clone)]
85pub struct FluidResult {
86 pub membership: Vec<u32>,
88 pub nb_clusters: u32,
93 pub n_iterations_run: u32,
96}
97
98pub fn fluid_communities(graph: &Graph, k: u32) -> IgraphResult<FluidResult> {
134 fluid_communities_with_options(graph, k, &FluidOptions::default())
135}
136
137pub fn fluid_communities_with_options(
160 graph: &Graph,
161 k: u32,
162 opts: &FluidOptions,
163) -> IgraphResult<FluidResult> {
164 let n = graph.vcount();
165
166 if n < 2 {
170 if k == 0 {
171 return Err(IgraphError::InvalidArgument(
172 "number of requested communities must be positive".to_string(),
173 ));
174 }
175 return Ok(FluidResult {
176 membership: vec![0; n as usize],
177 nb_clusters: u32::from(n == 1),
178 n_iterations_run: 0,
179 });
180 }
181
182 if k == 0 {
183 return Err(IgraphError::InvalidArgument(
184 "number of requested communities must be positive".to_string(),
185 ));
186 }
187 if k > n {
188 return Err(IgraphError::InvalidArgument(format!(
189 "number of requested communities ({k}) must not exceed the number of vertices ({n})"
190 )));
191 }
192 if opts.max_iterations == 0 {
193 return Err(IgraphError::Unsupported(
194 "FluidOptions.max_iterations must be ≥ 1",
195 ));
196 }
197
198 let simple = crate::algorithms::properties::is_simple::is_simple(graph)?;
200 if !simple {
201 return Err(IgraphError::InvalidArgument(
202 "fluid community detection requires a simple graph (no self-loops, no parallel edges)"
203 .to_string(),
204 ));
205 }
206 let components = crate::algorithms::connectivity::components::connected_components(graph)?;
207 if components.count != 1 {
208 return Err(IgraphError::InvalidArgument(
209 "fluid community detection requires a connected graph".to_string(),
210 ));
211 }
212
213 let adjacency = build_adjacency(graph)?;
217
218 let mut rng = SplitMix64::new(opts.seed);
219
220 let mut membership: Vec<u32> = vec![0; n as usize];
224 let mut com_to_numvertices: Vec<u32> = vec![0; k as usize];
225 let mut density: Vec<f64> = vec![1.0; k as usize];
226
227 let mut node_order: Vec<u32> = (0..n).collect();
230 shuffle_in_place(&mut node_order, &mut rng);
231 for i in 0..k as usize {
232 let v = node_order[i];
233 membership[v as usize] = u32::try_from(i).expect("k fits u32") + 1;
234 com_to_numvertices[i] = 1;
235 }
236
237 let mut label_counters: Vec<f64> = vec![0.0; k as usize];
239 let mut dominant_labels: Vec<u32> = Vec::with_capacity(k as usize);
240 let mut touched: Vec<u32> = Vec::new();
241
242 const EPS: f64 = 1e-4;
243
244 let mut iter_count: u32 = 0;
245 let mut running = true;
246 while running && iter_count < opts.max_iterations {
247 running = false;
248 iter_count += 1;
249 shuffle_in_place(&mut node_order, &mut rng);
250
251 for idx in 0..n as usize {
252 let v1 = node_order[idx];
253 dominant_labels.clear();
254 for &lbl in &touched {
255 label_counters[(lbl - 1) as usize] = 0.0;
256 }
257 touched.clear();
258
259 let kv1 = membership[v1 as usize];
260 let mut max_count = 0.0_f64;
261
262 if kv1 != 0 {
265 let d = density[(kv1 - 1) as usize];
266 label_counters[(kv1 - 1) as usize] = d;
267 touched.push(kv1);
268 max_count = d;
269 dominant_labels.push(kv1);
270 }
271
272 for &nbr in &adjacency[v1 as usize] {
274 let k_label = membership[nbr as usize];
275 if k_label == 0 {
276 continue;
277 }
278 let idx_l = (k_label - 1) as usize;
279 if label_counters[idx_l] == 0.0 {
280 touched.push(k_label);
281 }
282 label_counters[idx_l] += density[idx_l];
283 let diff = label_counters[idx_l] - max_count;
284 if diff > EPS {
285 max_count = label_counters[idx_l];
286 dominant_labels.clear();
287 dominant_labels.push(k_label);
288 } else if diff > -EPS {
289 if !dominant_labels.contains(&k_label) {
293 dominant_labels.push(k_label);
294 }
295 }
296 }
297
298 if dominant_labels.is_empty() {
299 continue;
300 }
301
302 if dominant_labels.contains(&kv1) {
306 continue;
307 }
308
309 running = true;
310 let pick_idx = rng.gen_index(dominant_labels.len());
311 let new_label = dominant_labels[pick_idx];
312
313 if kv1 != 0 {
314 com_to_numvertices[(kv1 - 1) as usize] -= 1;
315 density[(kv1 - 1) as usize] =
321 1.0 / f64::from(com_to_numvertices[(kv1 - 1) as usize]);
322 }
323
324 membership[v1 as usize] = new_label;
325 com_to_numvertices[(new_label - 1) as usize] += 1;
326 density[(new_label - 1) as usize] =
327 1.0 / f64::from(com_to_numvertices[(new_label - 1) as usize]);
328 }
329 }
330
331 let mut remap: Vec<i32> = vec![-1; k as usize];
335 let mut next_dense: u32 = 0;
336 let mut dense_membership: Vec<u32> = vec![0; n as usize];
337 for (v, &lbl) in membership.iter().enumerate() {
338 debug_assert!(lbl >= 1, "fluid: vertex {v} ended unlabelled");
339 let idx_l = (lbl - 1) as usize;
340 if remap[idx_l] < 0 {
341 remap[idx_l] = next_dense as i32;
342 next_dense += 1;
343 }
344 dense_membership[v] = remap[idx_l] as u32;
345 }
346
347 Ok(FluidResult {
348 membership: dense_membership,
349 nb_clusters: next_dense,
350 n_iterations_run: iter_count,
351 })
352}
353
354fn build_adjacency(graph: &Graph) -> IgraphResult<Vec<Vec<u32>>> {
362 let n = graph.vcount() as usize;
363 let mut adj: Vec<Vec<u32>> = vec![Vec::new(); n];
364 for v in 0..n as u32 {
365 for nbr in graph.neighbors(v)? {
366 adj[v as usize].push(nbr);
367 }
368 }
369 Ok(adj)
370}
371
372struct SplitMix64(u64);
377
378impl SplitMix64 {
379 fn new(seed: u64) -> Self {
380 Self(seed.wrapping_add(0x9E37_79B9_7F4A_7C15))
381 }
382 fn next_u64(&mut self) -> u64 {
383 self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
384 let mut z = self.0;
385 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
386 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
387 z ^ (z >> 31)
388 }
389 fn gen_index(&mut self, bound: usize) -> usize {
390 debug_assert!(bound > 0);
391 let r = self.next_u64() % (bound as u64);
392 usize::try_from(r).expect("bound fits in usize by construction")
393 }
394}
395
396fn shuffle_in_place<T>(slice: &mut [T], rng: &mut SplitMix64) {
397 let len = slice.len();
398 for i in (1..len).rev() {
399 let j = rng.gen_index(i + 1);
400 slice.swap(i, j);
401 }
402}
403
404#[cfg(test)]
409#[allow(clippy::float_cmp)]
410mod tests {
411 use super::*;
412
413 fn k_clique(n: u32) -> Graph {
414 let mut g = Graph::with_vertices(n);
415 for u in 0..n {
416 for v in (u + 1)..n {
417 g.add_edge(u, v).expect("clique edge");
418 }
419 }
420 g
421 }
422
423 fn two_cliques_with_bridge(size: u32) -> Graph {
424 let mut g = Graph::with_vertices(size * 2);
425 for u in 0..size {
426 for v in (u + 1)..size {
427 g.add_edge(u, v).expect("clique edge");
428 }
429 }
430 for u in size..size * 2 {
431 for v in (u + 1)..size * 2 {
432 g.add_edge(u, v).expect("clique edge");
433 }
434 }
435 g.add_edge(size - 1, size).expect("bridge edge");
436 g
437 }
438
439 fn assert_partition_well_formed(r: &FluidResult, expected_n: usize) {
440 assert_eq!(r.membership.len(), expected_n);
441 if expected_n == 0 {
442 assert_eq!(r.nb_clusters, 0);
443 return;
444 }
445 let max_label = *r.membership.iter().max().unwrap_or(&0);
446 assert!((max_label as usize) < expected_n);
447 assert_eq!(max_label + 1, r.nb_clusters);
448 let mut seen = vec![false; r.nb_clusters as usize];
449 for &m in &r.membership {
450 seen[m as usize] = true;
451 }
452 assert!(seen.into_iter().all(|b| b));
453 }
454
455 #[test]
456 fn k_equals_1_is_one_big_community() {
457 let g = k_clique(6);
458 let r = fluid_communities(&g, 1).unwrap();
459 assert_eq!(r.nb_clusters, 1);
460 for &m in &r.membership {
461 assert_eq!(m, 0);
462 }
463 }
464
465 #[test]
466 fn k_equals_n_is_singletons() {
467 let g = k_clique(5);
468 let r = fluid_communities(&g, 5).unwrap();
469 assert_partition_well_formed(&r, 5);
470 assert_eq!(r.nb_clusters, 5);
473 }
474
475 #[test]
476 fn two_k4_bridge_splits_at_bridge() {
477 let g = two_cliques_with_bridge(4);
478 let r = fluid_communities(&g, 2).unwrap();
479 assert_eq!(r.nb_clusters, 2);
480 for u in 0..4 {
481 assert_eq!(r.membership[u], r.membership[0]);
482 }
483 for u in 4..8 {
484 assert_eq!(r.membership[u], r.membership[4]);
485 }
486 assert_ne!(r.membership[0], r.membership[4]);
487 }
488
489 #[test]
490 fn determinism_under_seed() {
491 let g = two_cliques_with_bridge(5);
492 let opts = FluidOptions {
493 seed: 12345,
494 ..FluidOptions::default()
495 };
496 let a = fluid_communities_with_options(&g, 3, &opts).unwrap();
497 let b = fluid_communities_with_options(&g, 3, &opts).unwrap();
498 assert_eq!(a.membership, b.membership);
499 assert_eq!(a.nb_clusters, b.nb_clusters);
500 }
501
502 #[test]
503 fn different_seeds_can_differ() {
504 let g = two_cliques_with_bridge(5);
505 let a = fluid_communities_with_options(
506 &g,
507 3,
508 &FluidOptions {
509 seed: 1,
510 max_iterations: FLUID_DEFAULT_MAX_ITERATIONS,
511 },
512 )
513 .unwrap();
514 let b = fluid_communities_with_options(
515 &g,
516 3,
517 &FluidOptions {
518 seed: 99,
519 max_iterations: FLUID_DEFAULT_MAX_ITERATIONS,
520 },
521 )
522 .unwrap();
523 assert_partition_well_formed(&a, g.vcount() as usize);
525 assert_partition_well_formed(&b, g.vcount() as usize);
526 }
527
528 #[test]
529 fn empty_graph_returns_empty() {
530 let g = Graph::with_vertices(0);
531 let r = fluid_communities(&g, 1).unwrap();
532 assert_eq!(r.membership.len(), 0);
533 assert_eq!(r.nb_clusters, 0);
534 }
535
536 #[test]
537 fn single_vertex_returns_one_singleton() {
538 let g = Graph::with_vertices(1);
539 let r = fluid_communities(&g, 1).unwrap();
540 assert_eq!(r.membership.len(), 1);
541 assert_eq!(r.nb_clusters, 1);
542 }
543
544 #[test]
545 fn rejects_k_zero() {
546 let g = k_clique(4);
547 assert!(fluid_communities(&g, 0).is_err());
548 }
549
550 #[test]
551 fn rejects_k_greater_than_vcount() {
552 let g = k_clique(4);
553 assert!(fluid_communities(&g, 5).is_err());
554 }
555
556 #[test]
557 fn rejects_disconnected_graph() {
558 let mut g = Graph::with_vertices(4);
559 g.add_edge(0, 1).unwrap();
560 g.add_edge(2, 3).unwrap();
561 assert!(fluid_communities(&g, 2).is_err());
562 }
563
564 #[test]
565 fn rejects_non_simple_graph() {
566 let mut g = Graph::with_vertices(3);
567 g.add_edge(0, 1).unwrap();
568 g.add_edge(1, 2).unwrap();
569 g.add_edge(0, 1).unwrap(); assert!(fluid_communities(&g, 2).is_err());
571 }
572
573 #[test]
574 fn rejects_max_iterations_zero() {
575 let g = k_clique(4);
576 let opts = FluidOptions {
577 seed: 0,
578 max_iterations: 0,
579 };
580 assert!(fluid_communities_with_options(&g, 2, &opts).is_err());
581 }
582
583 #[test]
584 fn ring_of_4_k5_cliques_finds_4_groups() {
585 let mut g = Graph::with_vertices(20);
587 for c in 0..4 {
588 let base = c * 5;
589 for u in 0..5 {
590 for v in (u + 1)..5 {
591 g.add_edge(base + u, base + v).unwrap();
592 }
593 }
594 let next_base = ((c + 1) % 4) * 5;
595 g.add_edge(base, next_base).unwrap();
596 }
597 let r = fluid_communities_with_options(
598 &g,
599 4,
600 &FluidOptions {
601 seed: 7,
602 max_iterations: FLUID_DEFAULT_MAX_ITERATIONS,
603 },
604 )
605 .unwrap();
606 assert_eq!(r.nb_clusters, 4);
607 for c in 0..4 {
608 let base = (c * 5) as usize;
609 let label = r.membership[base];
610 for offset in 1..5 {
611 assert_eq!(r.membership[base + offset], label);
612 }
613 }
614 }
615
616 #[test]
617 fn converges_in_reasonable_iterations() {
618 let g = two_cliques_with_bridge(6);
619 let r = fluid_communities_with_options(
620 &g,
621 2,
622 &FluidOptions {
623 seed: 0,
624 max_iterations: 100,
625 },
626 )
627 .unwrap();
628 assert!(r.n_iterations_run <= 100);
630 assert!(r.n_iterations_run >= 1);
631 }
632}