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        let label = u32::try_from(i)
234            .map_err(|_| IgraphError::InvalidArgument("k exceeds u32 range".into()))?
235            + 1;
236        membership[v as usize] = label;
237        com_to_numvertices[i] = 1;
238    }
239
240    // Working buffers reused across iterations.
241    let mut label_counters: Vec<f64> = vec![0.0; k as usize];
242    let mut dominant_labels: Vec<u32> = Vec::with_capacity(k as usize);
243    let mut touched: Vec<u32> = Vec::new();
244
245    const EPS: f64 = 1e-4;
246
247    let mut iter_count: u32 = 0;
248    let mut running = true;
249    while running && iter_count < opts.max_iterations {
250        running = false;
251        iter_count += 1;
252        shuffle_in_place(&mut node_order, &mut rng);
253
254        for idx in 0..n as usize {
255            let v1 = node_order[idx];
256            dominant_labels.clear();
257            for &lbl in &touched {
258                label_counters[(lbl - 1) as usize] = 0.0;
259            }
260            touched.clear();
261
262            let kv1 = membership[v1 as usize];
263            let mut max_count = 0.0_f64;
264
265            // Same-label retention: the vertex's own current label is
266            // pre-loaded into the dominant set so a tie keeps it.
267            if kv1 != 0 {
268                let d = density[(kv1 - 1) as usize];
269                label_counters[(kv1 - 1) as usize] = d;
270                touched.push(kv1);
271                max_count = d;
272                dominant_labels.push(kv1);
273            }
274
275            // Density-weighted neighbour tally.
276            for &nbr in &adjacency[v1 as usize] {
277                let k_label = membership[nbr as usize];
278                if k_label == 0 {
279                    continue;
280                }
281                let idx_l = (k_label - 1) as usize;
282                if label_counters[idx_l] == 0.0 {
283                    touched.push(k_label);
284                }
285                label_counters[idx_l] += density[idx_l];
286                let diff = label_counters[idx_l] - max_count;
287                if diff > EPS {
288                    max_count = label_counters[idx_l];
289                    dominant_labels.clear();
290                    dominant_labels.push(k_label);
291                } else if diff > -EPS {
292                    // Within tie band — append if not already present.
293                    // (Same-label retention has already injected kv1 if
294                    // it ties.) Avoid duplicate inserts for performance.
295                    if !dominant_labels.contains(&k_label) {
296                        dominant_labels.push(k_label);
297                    }
298                }
299            }
300
301            if dominant_labels.is_empty() {
302                continue;
303            }
304
305            // If the current label is still tied for dominant, keep it
306            // (no iteration is needed for this vertex). Otherwise
307            // sample uniformly from the dominant set.
308            if dominant_labels.contains(&kv1) {
309                continue;
310            }
311
312            running = true;
313            let pick_idx = rng.gen_index(dominant_labels.len());
314            let new_label = dominant_labels[pick_idx];
315
316            if kv1 != 0 {
317                com_to_numvertices[(kv1 - 1) as usize] -= 1;
318                // Density spike to +inf when a community empties is a
319                // known feature: it pulls a neighbour back into the
320                // emptied community on the next iteration, restoring
321                // the seed. We do NOT special-case this; IEEE-754
322                // arithmetic handles inf cleanly through the tally.
323                density[(kv1 - 1) as usize] =
324                    1.0 / f64::from(com_to_numvertices[(kv1 - 1) as usize]);
325            }
326
327            membership[v1 as usize] = new_label;
328            com_to_numvertices[(new_label - 1) as usize] += 1;
329            density[(new_label - 1) as usize] =
330                1.0 / f64::from(com_to_numvertices[(new_label - 1) as usize]);
331        }
332    }
333
334    // Drop the 1-based offset; densify by counting how many distinct
335    // labels actually survived (usually exactly k, but a community can
336    // vanish under pathological dynamics).
337    let mut remap: Vec<i32> = vec![-1; k as usize];
338    let mut next_dense: u32 = 0;
339    let mut dense_membership: Vec<u32> = vec![0; n as usize];
340    for (v, &lbl) in membership.iter().enumerate() {
341        debug_assert!(lbl >= 1, "fluid: vertex {v} ended unlabelled");
342        let idx_l = (lbl - 1) as usize;
343        if remap[idx_l] < 0 {
344            remap[idx_l] = next_dense as i32;
345            next_dense += 1;
346        }
347        dense_membership[v] = remap[idx_l] as u32;
348    }
349
350    Ok(FluidResult {
351        membership: dense_membership,
352        nb_clusters: next_dense,
353        n_iterations_run: iter_count,
354    })
355}
356
357// ============================================================================
358//                              Helpers
359// ============================================================================
360
361/// Build an all-mode adjacency list for the undirected interpretation
362/// of the graph. Self-loops and parallel edges have already been
363/// rejected upstream by the `is_simple` check, so each entry is unique.
364fn build_adjacency(graph: &Graph) -> IgraphResult<Vec<Vec<u32>>> {
365    let n = graph.vcount() as usize;
366    let mut adj: Vec<Vec<u32>> = vec![Vec::new(); n];
367    for v in 0..n as u32 {
368        for nbr in graph.neighbors(v)? {
369            adj[v as usize].push(nbr);
370        }
371    }
372    Ok(adj)
373}
374
375// ============================================================================
376//                                  PRNG
377// ============================================================================
378
379struct SplitMix64(u64);
380
381impl SplitMix64 {
382    fn new(seed: u64) -> Self {
383        Self(seed.wrapping_add(0x9E37_79B9_7F4A_7C15))
384    }
385    fn next_u64(&mut self) -> u64 {
386        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
387        let mut z = self.0;
388        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
389        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
390        z ^ (z >> 31)
391    }
392    fn gen_index(&mut self, bound: usize) -> usize {
393        debug_assert!(bound > 0);
394        let r = self.next_u64() % (bound as u64);
395        usize::try_from(r).unwrap_or(0)
396    }
397}
398
399fn shuffle_in_place<T>(slice: &mut [T], rng: &mut SplitMix64) {
400    let len = slice.len();
401    for i in (1..len).rev() {
402        let j = rng.gen_index(i + 1);
403        slice.swap(i, j);
404    }
405}
406
407// ============================================================================
408//                                  Tests
409// ============================================================================
410
411#[cfg(test)]
412#[allow(clippy::float_cmp)]
413mod tests {
414    use super::*;
415
416    fn k_clique(n: u32) -> Graph {
417        let mut g = Graph::with_vertices(n);
418        for u in 0..n {
419            for v in (u + 1)..n {
420                g.add_edge(u, v).expect("clique edge");
421            }
422        }
423        g
424    }
425
426    fn two_cliques_with_bridge(size: u32) -> Graph {
427        let mut g = Graph::with_vertices(size * 2);
428        for u in 0..size {
429            for v in (u + 1)..size {
430                g.add_edge(u, v).expect("clique edge");
431            }
432        }
433        for u in size..size * 2 {
434            for v in (u + 1)..size * 2 {
435                g.add_edge(u, v).expect("clique edge");
436            }
437        }
438        g.add_edge(size - 1, size).expect("bridge edge");
439        g
440    }
441
442    fn assert_partition_well_formed(r: &FluidResult, expected_n: usize) {
443        assert_eq!(r.membership.len(), expected_n);
444        if expected_n == 0 {
445            assert_eq!(r.nb_clusters, 0);
446            return;
447        }
448        let max_label = *r.membership.iter().max().unwrap_or(&0);
449        assert!((max_label as usize) < expected_n);
450        assert_eq!(max_label + 1, r.nb_clusters);
451        let mut seen = vec![false; r.nb_clusters as usize];
452        for &m in &r.membership {
453            seen[m as usize] = true;
454        }
455        assert!(seen.into_iter().all(|b| b));
456    }
457
458    #[test]
459    fn k_equals_1_is_one_big_community() {
460        let g = k_clique(6);
461        let r = fluid_communities(&g, 1).unwrap();
462        assert_eq!(r.nb_clusters, 1);
463        for &m in &r.membership {
464            assert_eq!(m, 0);
465        }
466    }
467
468    #[test]
469    fn k_equals_n_is_singletons() {
470        let g = k_clique(5);
471        let r = fluid_communities(&g, 5).unwrap();
472        assert_partition_well_formed(&r, 5);
473        // In K5 with k=5, every vertex starts as its own community and
474        // density 1.0; the labels are stable from iteration 1.
475        assert_eq!(r.nb_clusters, 5);
476    }
477
478    #[test]
479    fn two_k4_bridge_splits_at_bridge() {
480        let g = two_cliques_with_bridge(4);
481        let r = fluid_communities(&g, 2).unwrap();
482        assert_eq!(r.nb_clusters, 2);
483        for u in 0..4 {
484            assert_eq!(r.membership[u], r.membership[0]);
485        }
486        for u in 4..8 {
487            assert_eq!(r.membership[u], r.membership[4]);
488        }
489        assert_ne!(r.membership[0], r.membership[4]);
490    }
491
492    #[test]
493    fn determinism_under_seed() {
494        let g = two_cliques_with_bridge(5);
495        let opts = FluidOptions {
496            seed: 12345,
497            ..FluidOptions::default()
498        };
499        let a = fluid_communities_with_options(&g, 3, &opts).unwrap();
500        let b = fluid_communities_with_options(&g, 3, &opts).unwrap();
501        assert_eq!(a.membership, b.membership);
502        assert_eq!(a.nb_clusters, b.nb_clusters);
503    }
504
505    #[test]
506    fn different_seeds_can_differ() {
507        let g = two_cliques_with_bridge(5);
508        let a = fluid_communities_with_options(
509            &g,
510            3,
511            &FluidOptions {
512                seed: 1,
513                max_iterations: FLUID_DEFAULT_MAX_ITERATIONS,
514            },
515        )
516        .unwrap();
517        let b = fluid_communities_with_options(
518            &g,
519            3,
520            &FluidOptions {
521                seed: 99,
522                max_iterations: FLUID_DEFAULT_MAX_ITERATIONS,
523            },
524        )
525        .unwrap();
526        // Either equal or different — we just verify both shapes are valid.
527        assert_partition_well_formed(&a, g.vcount() as usize);
528        assert_partition_well_formed(&b, g.vcount() as usize);
529    }
530
531    #[test]
532    fn empty_graph_returns_empty() {
533        let g = Graph::with_vertices(0);
534        let r = fluid_communities(&g, 1).unwrap();
535        assert_eq!(r.membership.len(), 0);
536        assert_eq!(r.nb_clusters, 0);
537    }
538
539    #[test]
540    fn single_vertex_returns_one_singleton() {
541        let g = Graph::with_vertices(1);
542        let r = fluid_communities(&g, 1).unwrap();
543        assert_eq!(r.membership.len(), 1);
544        assert_eq!(r.nb_clusters, 1);
545    }
546
547    #[test]
548    fn rejects_k_zero() {
549        let g = k_clique(4);
550        assert!(fluid_communities(&g, 0).is_err());
551    }
552
553    #[test]
554    fn rejects_k_greater_than_vcount() {
555        let g = k_clique(4);
556        assert!(fluid_communities(&g, 5).is_err());
557    }
558
559    #[test]
560    fn rejects_disconnected_graph() {
561        let mut g = Graph::with_vertices(4);
562        g.add_edge(0, 1).unwrap();
563        g.add_edge(2, 3).unwrap();
564        assert!(fluid_communities(&g, 2).is_err());
565    }
566
567    #[test]
568    fn rejects_non_simple_graph() {
569        let mut g = Graph::with_vertices(3);
570        g.add_edge(0, 1).unwrap();
571        g.add_edge(1, 2).unwrap();
572        g.add_edge(0, 1).unwrap(); // parallel edge
573        assert!(fluid_communities(&g, 2).is_err());
574    }
575
576    #[test]
577    fn rejects_max_iterations_zero() {
578        let g = k_clique(4);
579        let opts = FluidOptions {
580            seed: 0,
581            max_iterations: 0,
582        };
583        assert!(fluid_communities_with_options(&g, 2, &opts).is_err());
584    }
585
586    #[test]
587    fn ring_of_4_k5_cliques_finds_4_groups() {
588        // 4 K5 cliques joined in a ring.
589        let mut g = Graph::with_vertices(20);
590        for c in 0..4 {
591            let base = c * 5;
592            for u in 0..5 {
593                for v in (u + 1)..5 {
594                    g.add_edge(base + u, base + v).unwrap();
595                }
596            }
597            let next_base = ((c + 1) % 4) * 5;
598            g.add_edge(base, next_base).unwrap();
599        }
600        let r = fluid_communities_with_options(
601            &g,
602            4,
603            &FluidOptions {
604                seed: 7,
605                max_iterations: FLUID_DEFAULT_MAX_ITERATIONS,
606            },
607        )
608        .unwrap();
609        assert_eq!(r.nb_clusters, 4);
610        for c in 0..4 {
611            let base = (c * 5) as usize;
612            let label = r.membership[base];
613            for offset in 1..5 {
614                assert_eq!(r.membership[base + offset], label);
615            }
616        }
617    }
618
619    #[test]
620    fn converges_in_reasonable_iterations() {
621        let g = two_cliques_with_bridge(6);
622        let r = fluid_communities_with_options(
623            &g,
624            2,
625            &FluidOptions {
626                seed: 0,
627                max_iterations: 100,
628            },
629        )
630        .unwrap();
631        // K6+K6 with bridge converges in ≤ ~10 iterations from any seed.
632        assert!(r.n_iterations_run <= 100);
633        assert!(r.n_iterations_run >= 1);
634    }
635}