Skip to main content

rust_igraph/algorithms/community/
fluid_communities.rs

1//! Fluid communities (ALGO-CO-005).
2//!
3//! Counterpart of `igraph_community_fluid_communities()` from
4//! `references/igraph/src/community/fluid.c`.
5//!
6//! Parés F., Gasulla D.G. *et al.* (2018): *Fluid Communities: A
7//! Competitive, Scalable and Diverse Community Detection Algorithm.*
8//! Complex Networks & Their Applications VI, Springer, vol 689, p 229.
9//! <https://doi.org/10.1007/978-3-319-72150-7_19>
10//!
11//! The algorithm seeds `k` distinct fluids (communities) at random
12//! vertices, each with density `1/size`. In each iteration the vertex
13//! order is shuffled and every vertex re-evaluates its label by summing
14//! density contributions from itself and its neighbours, picking the
15//! dominant label (with ε = 1e-4 tie tolerance and random tie-break).
16//! A vertex retains its current label whenever it is still tied for
17//! dominant. Iteration stops when no vertex changes label.
18//!
19//! Constraints (mirrors upstream):
20//!
21//! - The graph must be **simple** (no self-loops nor parallel edges).
22//! - The graph must be **connected** (weak connectivity is sufficient;
23//!   directions are ignored with a soft note).
24//! - The graph must be **undirected** (directions are ignored, matches
25//!   upstream's warning behaviour — surfaced here via a non-fatal
26//!   acceptance because we treat directed edges as undirected for the
27//!   purpose of community detection).
28//! - `1 ≤ k ≤ vcount`.
29//! - Weights are **not** supported (no weighted variant exists upstream).
30//!
31//! Self-rolled, dependency-free. Determinism comes from an inline
32//! `SplitMix64` PRNG seeded by the caller; the convenience entrypoint
33//! [`fluid_communities`] pins `seed = 0` so repeated calls on the same
34//! graph produce identical partitions.
35
36// All `usize` -> `u32` casts in this module are bounded by `n`
37// (graph vertex count) which originates from `Graph::vcount(): u32`
38// and so can never truncate. `float_cmp` is allowed because the
39// dominant-label loop intentionally compares two label-tally f64s for
40// exact ordering after an ε-band rejection (mirroring upstream).
41#![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/// Full option bag for [`fluid_communities_with_options`].
56#[derive(Debug, Clone)]
57pub struct FluidOptions {
58    /// PRNG seed driving the initial seed-vertex selection, the
59    /// per-iteration node-order shuffle, and the tie-break amongst
60    /// dominant labels. Default `0`.
61    pub seed: u64,
62    /// Safety cap on the number of outer iterations. Upstream loops
63    /// `while (running)` with no cap; Parés *et al.* note that
64    /// convergence is empirically observed in O(few) iterations, but
65    /// adversarial inputs can in principle oscillate, so we expose a
66    /// configurable upper bound. Default [`FLUID_DEFAULT_MAX_ITERATIONS`].
67    pub max_iterations: u32,
68}
69
70/// Default safety cap on outer iterations for
71/// [`fluid_communities`] / [`fluid_communities_with_options`].
72pub 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/// Result of [`fluid_communities`] / [`fluid_communities_with_options`].
84#[derive(Debug, Clone)]
85pub struct FluidResult {
86    /// Per-vertex community label, densely numbered in `0..nb_clusters`.
87    pub membership: Vec<u32>,
88    /// Number of distinct labels actually present. Normally equals the
89    /// requested `k`, but a community can vanish in pathological
90    /// graphs; we expose the *actual* count so downstream code stays
91    /// honest.
92    pub nb_clusters: u32,
93    /// Number of outer iterations actually executed (1 ≤ x ≤
94    /// `max_iterations`).
95    pub n_iterations_run: u32,
96}
97
98/// Run Fluid Communities with default options
99/// (seed = 0, `max_iterations` = [`FLUID_DEFAULT_MAX_ITERATIONS`]).
100///
101/// `k` is the number of communities to find; it must satisfy
102/// `1 ≤ k ≤ vcount`. Self-loops, parallel edges, disconnected
103/// components, and weighted graphs are all rejected.
104///
105/// # Errors
106/// - [`IgraphError::InvalidArgument`] if `k = 0`, `k > vcount`, or the
107///   graph is not simple / not connected.
108///
109/// # Examples
110///
111/// ```
112/// use rust_igraph::{Graph, fluid_communities};
113///
114/// // Two K4 cliques joined by a single bridge edge — the two-community
115/// // partition is the obvious result for k=2.
116/// let mut g = Graph::with_vertices(8);
117/// for u in 0..4 {
118///     for v in (u + 1)..4 {
119///         g.add_edge(u, v).unwrap();
120///     }
121/// }
122/// for u in 4..8 {
123///     for v in (u + 1)..8 {
124///         g.add_edge(u, v).unwrap();
125///     }
126/// }
127/// g.add_edge(3, 4).unwrap();
128/// let r = fluid_communities(&g, 2).unwrap();
129/// assert_eq!(r.membership[0], r.membership[1]);
130/// assert_eq!(r.membership[4], r.membership[5]);
131/// assert_ne!(r.membership[0], r.membership[4]);
132/// ```
133pub fn fluid_communities(graph: &Graph, k: u32) -> IgraphResult<FluidResult> {
134    fluid_communities_with_options(graph, k, &FluidOptions::default())
135}
136
137/// Run Fluid Communities with full option control.
138///
139/// See [`fluid_communities`] for the argument and error contract;
140/// [`FluidOptions`] adds reproducible seeding and an iteration cap.
141///
142/// # Errors
143/// Same as [`fluid_communities`], plus:
144/// - [`IgraphError::Unsupported`] if `max_iterations = 0`.
145///
146/// # Examples
147///
148/// ```
149/// use rust_igraph::{Graph, FluidOptions, fluid_communities_with_options};
150///
151/// let mut g = Graph::with_vertices(6);
152/// for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
153///     g.add_edge(u, v).unwrap();
154/// }
155/// let opts = FluidOptions { seed: 42, ..FluidOptions::default() };
156/// let r = fluid_communities_with_options(&g, 2, &opts).unwrap();
157/// assert_eq!(r.nb_clusters, 2);
158/// ```
159pub fn fluid_communities_with_options(
160    graph: &Graph,
161    k: u32,
162    opts: &FluidOptions,
163) -> IgraphResult<FluidResult> {
164    let n = graph.vcount();
165
166    // Edge case: vcount < 2 ⇒ trivially one community of zero or one
167    // vertex (matches upstream's null-graph convenience). We still
168    // require k ≥ 1.
169    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    // Mirror upstream simplicity + connectivity gating.
199    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    // Build the all-mode adjacency once; we walk it twice per vertex
214    // visit (one tally pass) and the graph is undirected for community
215    // detection purposes.
216    let adjacency = build_adjacency(graph)?;
217
218    let mut rng = SplitMix64::new(opts.seed);
219
220    // Membership uses 1-based labels internally to reserve 0 = "unlabelled"
221    // (matching upstream's `kv1 == 0` shortcut). We strip the offset at
222    // the very end.
223    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    // Shuffle node order and seed the first k communities with the
228    // first k vertices of the permutation.
229    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    // Working buffers reused across iterations.
238    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            // Same-label retention: the vertex's own current label is
263            // pre-loaded into the dominant set so a tie keeps it.
264            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            // Density-weighted neighbour tally.
273            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                    // Within tie band — append if not already present.
290                    // (Same-label retention has already injected kv1 if
291                    // it ties.) Avoid duplicate inserts for performance.
292                    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 the current label is still tied for dominant, keep it
303            // (no iteration is needed for this vertex). Otherwise
304            // sample uniformly from the dominant set.
305            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 spike to +inf when a community empties is a
316                // known feature: it pulls a neighbour back into the
317                // emptied community on the next iteration, restoring
318                // the seed. We do NOT special-case this; IEEE-754
319                // arithmetic handles inf cleanly through the tally.
320                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    // Drop the 1-based offset; densify by counting how many distinct
332    // labels actually survived (usually exactly k, but a community can
333    // vanish under pathological dynamics).
334    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
354// ============================================================================
355//                              Helpers
356// ============================================================================
357
358/// Build an all-mode adjacency list for the undirected interpretation
359/// of the graph. Self-loops and parallel edges have already been
360/// rejected upstream by the `is_simple` check, so each entry is unique.
361fn 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
372// ============================================================================
373//                                  PRNG
374// ============================================================================
375
376struct 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// ============================================================================
405//                                  Tests
406// ============================================================================
407
408#[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        // In K5 with k=5, every vertex starts as its own community and
471        // density 1.0; the labels are stable from iteration 1.
472        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        // Either equal or different — we just verify both shapes are valid.
524        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(); // parallel edge
570        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        // 4 K5 cliques joined in a ring.
586        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        // K6+K6 with bridge converges in ≤ ~10 iterations from any seed.
629        assert!(r.n_iterations_run <= 100);
630        assert!(r.n_iterations_run >= 1);
631    }
632}