#![allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::cast_sign_loss,
clippy::float_cmp,
clippy::items_after_statements,
clippy::many_single_char_names,
clippy::needless_range_loop,
clippy::too_many_lines
)]
use crate::core::{Graph, IgraphError, IgraphResult};
#[derive(Debug, Clone)]
pub struct FluidOptions {
pub seed: u64,
pub max_iterations: u32,
}
pub const FLUID_DEFAULT_MAX_ITERATIONS: u32 = 1000;
impl Default for FluidOptions {
fn default() -> Self {
Self {
seed: 0,
max_iterations: FLUID_DEFAULT_MAX_ITERATIONS,
}
}
}
#[derive(Debug, Clone)]
pub struct FluidResult {
pub membership: Vec<u32>,
pub nb_clusters: u32,
pub n_iterations_run: u32,
}
pub fn fluid_communities(graph: &Graph, k: u32) -> IgraphResult<FluidResult> {
fluid_communities_with_options(graph, k, &FluidOptions::default())
}
pub fn fluid_communities_with_options(
graph: &Graph,
k: u32,
opts: &FluidOptions,
) -> IgraphResult<FluidResult> {
let n = graph.vcount();
if n < 2 {
if k == 0 {
return Err(IgraphError::InvalidArgument(
"number of requested communities must be positive".to_string(),
));
}
return Ok(FluidResult {
membership: vec![0; n as usize],
nb_clusters: u32::from(n == 1),
n_iterations_run: 0,
});
}
if k == 0 {
return Err(IgraphError::InvalidArgument(
"number of requested communities must be positive".to_string(),
));
}
if k > n {
return Err(IgraphError::InvalidArgument(format!(
"number of requested communities ({k}) must not exceed the number of vertices ({n})"
)));
}
if opts.max_iterations == 0 {
return Err(IgraphError::Unsupported(
"FluidOptions.max_iterations must be ≥ 1",
));
}
let simple = crate::algorithms::properties::is_simple::is_simple(graph)?;
if !simple {
return Err(IgraphError::InvalidArgument(
"fluid community detection requires a simple graph (no self-loops, no parallel edges)"
.to_string(),
));
}
let components = crate::algorithms::connectivity::components::connected_components(graph)?;
if components.count != 1 {
return Err(IgraphError::InvalidArgument(
"fluid community detection requires a connected graph".to_string(),
));
}
let adjacency = build_adjacency(graph)?;
let mut rng = SplitMix64::new(opts.seed);
let mut membership: Vec<u32> = vec![0; n as usize];
let mut com_to_numvertices: Vec<u32> = vec![0; k as usize];
let mut density: Vec<f64> = vec![1.0; k as usize];
let mut node_order: Vec<u32> = (0..n).collect();
shuffle_in_place(&mut node_order, &mut rng);
for i in 0..k as usize {
let v = node_order[i];
let label = u32::try_from(i)
.map_err(|_| IgraphError::InvalidArgument("k exceeds u32 range".into()))?
+ 1;
membership[v as usize] = label;
com_to_numvertices[i] = 1;
}
let mut label_counters: Vec<f64> = vec![0.0; k as usize];
let mut dominant_labels: Vec<u32> = Vec::with_capacity(k as usize);
let mut touched: Vec<u32> = Vec::new();
const EPS: f64 = 1e-4;
let mut iter_count: u32 = 0;
let mut running = true;
while running && iter_count < opts.max_iterations {
running = false;
iter_count += 1;
shuffle_in_place(&mut node_order, &mut rng);
for idx in 0..n as usize {
let v1 = node_order[idx];
dominant_labels.clear();
for &lbl in &touched {
label_counters[(lbl - 1) as usize] = 0.0;
}
touched.clear();
let kv1 = membership[v1 as usize];
let mut max_count = 0.0_f64;
if kv1 != 0 {
let d = density[(kv1 - 1) as usize];
label_counters[(kv1 - 1) as usize] = d;
touched.push(kv1);
max_count = d;
dominant_labels.push(kv1);
}
for &nbr in &adjacency[v1 as usize] {
let k_label = membership[nbr as usize];
if k_label == 0 {
continue;
}
let idx_l = (k_label - 1) as usize;
if label_counters[idx_l] == 0.0 {
touched.push(k_label);
}
label_counters[idx_l] += density[idx_l];
let diff = label_counters[idx_l] - max_count;
if diff > EPS {
max_count = label_counters[idx_l];
dominant_labels.clear();
dominant_labels.push(k_label);
} else if diff > -EPS {
if !dominant_labels.contains(&k_label) {
dominant_labels.push(k_label);
}
}
}
if dominant_labels.is_empty() {
continue;
}
if dominant_labels.contains(&kv1) {
continue;
}
running = true;
let pick_idx = rng.gen_index(dominant_labels.len());
let new_label = dominant_labels[pick_idx];
if kv1 != 0 {
com_to_numvertices[(kv1 - 1) as usize] -= 1;
density[(kv1 - 1) as usize] =
1.0 / f64::from(com_to_numvertices[(kv1 - 1) as usize]);
}
membership[v1 as usize] = new_label;
com_to_numvertices[(new_label - 1) as usize] += 1;
density[(new_label - 1) as usize] =
1.0 / f64::from(com_to_numvertices[(new_label - 1) as usize]);
}
}
let mut remap: Vec<i32> = vec![-1; k as usize];
let mut next_dense: u32 = 0;
let mut dense_membership: Vec<u32> = vec![0; n as usize];
for (v, &lbl) in membership.iter().enumerate() {
debug_assert!(lbl >= 1, "fluid: vertex {v} ended unlabelled");
let idx_l = (lbl - 1) as usize;
if remap[idx_l] < 0 {
remap[idx_l] = next_dense as i32;
next_dense += 1;
}
dense_membership[v] = remap[idx_l] as u32;
}
Ok(FluidResult {
membership: dense_membership,
nb_clusters: next_dense,
n_iterations_run: iter_count,
})
}
fn build_adjacency(graph: &Graph) -> IgraphResult<Vec<Vec<u32>>> {
let n = graph.vcount() as usize;
let mut adj: Vec<Vec<u32>> = vec![Vec::new(); n];
for v in 0..n as u32 {
for nbr in graph.neighbors(v)? {
adj[v as usize].push(nbr);
}
}
Ok(adj)
}
struct SplitMix64(u64);
impl SplitMix64 {
fn new(seed: u64) -> Self {
Self(seed.wrapping_add(0x9E37_79B9_7F4A_7C15))
}
fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn gen_index(&mut self, bound: usize) -> usize {
debug_assert!(bound > 0);
let r = self.next_u64() % (bound as u64);
usize::try_from(r).unwrap_or(0)
}
}
fn shuffle_in_place<T>(slice: &mut [T], rng: &mut SplitMix64) {
let len = slice.len();
for i in (1..len).rev() {
let j = rng.gen_index(i + 1);
slice.swap(i, j);
}
}
#[cfg(test)]
#[allow(clippy::float_cmp)]
mod tests {
use super::*;
fn k_clique(n: u32) -> Graph {
let mut g = Graph::with_vertices(n);
for u in 0..n {
for v in (u + 1)..n {
g.add_edge(u, v).expect("clique edge");
}
}
g
}
fn two_cliques_with_bridge(size: u32) -> Graph {
let mut g = Graph::with_vertices(size * 2);
for u in 0..size {
for v in (u + 1)..size {
g.add_edge(u, v).expect("clique edge");
}
}
for u in size..size * 2 {
for v in (u + 1)..size * 2 {
g.add_edge(u, v).expect("clique edge");
}
}
g.add_edge(size - 1, size).expect("bridge edge");
g
}
fn assert_partition_well_formed(r: &FluidResult, expected_n: usize) {
assert_eq!(r.membership.len(), expected_n);
if expected_n == 0 {
assert_eq!(r.nb_clusters, 0);
return;
}
let max_label = *r.membership.iter().max().unwrap_or(&0);
assert!((max_label as usize) < expected_n);
assert_eq!(max_label + 1, r.nb_clusters);
let mut seen = vec![false; r.nb_clusters as usize];
for &m in &r.membership {
seen[m as usize] = true;
}
assert!(seen.into_iter().all(|b| b));
}
#[test]
fn k_equals_1_is_one_big_community() {
let g = k_clique(6);
let r = fluid_communities(&g, 1).unwrap();
assert_eq!(r.nb_clusters, 1);
for &m in &r.membership {
assert_eq!(m, 0);
}
}
#[test]
fn k_equals_n_is_singletons() {
let g = k_clique(5);
let r = fluid_communities(&g, 5).unwrap();
assert_partition_well_formed(&r, 5);
assert_eq!(r.nb_clusters, 5);
}
#[test]
fn two_k4_bridge_splits_at_bridge() {
let g = two_cliques_with_bridge(4);
let r = fluid_communities(&g, 2).unwrap();
assert_eq!(r.nb_clusters, 2);
for u in 0..4 {
assert_eq!(r.membership[u], r.membership[0]);
}
for u in 4..8 {
assert_eq!(r.membership[u], r.membership[4]);
}
assert_ne!(r.membership[0], r.membership[4]);
}
#[test]
fn determinism_under_seed() {
let g = two_cliques_with_bridge(5);
let opts = FluidOptions {
seed: 12345,
..FluidOptions::default()
};
let a = fluid_communities_with_options(&g, 3, &opts).unwrap();
let b = fluid_communities_with_options(&g, 3, &opts).unwrap();
assert_eq!(a.membership, b.membership);
assert_eq!(a.nb_clusters, b.nb_clusters);
}
#[test]
fn different_seeds_can_differ() {
let g = two_cliques_with_bridge(5);
let a = fluid_communities_with_options(
&g,
3,
&FluidOptions {
seed: 1,
max_iterations: FLUID_DEFAULT_MAX_ITERATIONS,
},
)
.unwrap();
let b = fluid_communities_with_options(
&g,
3,
&FluidOptions {
seed: 99,
max_iterations: FLUID_DEFAULT_MAX_ITERATIONS,
},
)
.unwrap();
assert_partition_well_formed(&a, g.vcount() as usize);
assert_partition_well_formed(&b, g.vcount() as usize);
}
#[test]
fn empty_graph_returns_empty() {
let g = Graph::with_vertices(0);
let r = fluid_communities(&g, 1).unwrap();
assert_eq!(r.membership.len(), 0);
assert_eq!(r.nb_clusters, 0);
}
#[test]
fn single_vertex_returns_one_singleton() {
let g = Graph::with_vertices(1);
let r = fluid_communities(&g, 1).unwrap();
assert_eq!(r.membership.len(), 1);
assert_eq!(r.nb_clusters, 1);
}
#[test]
fn rejects_k_zero() {
let g = k_clique(4);
assert!(fluid_communities(&g, 0).is_err());
}
#[test]
fn rejects_k_greater_than_vcount() {
let g = k_clique(4);
assert!(fluid_communities(&g, 5).is_err());
}
#[test]
fn rejects_disconnected_graph() {
let mut g = Graph::with_vertices(4);
g.add_edge(0, 1).unwrap();
g.add_edge(2, 3).unwrap();
assert!(fluid_communities(&g, 2).is_err());
}
#[test]
fn rejects_non_simple_graph() {
let mut g = Graph::with_vertices(3);
g.add_edge(0, 1).unwrap();
g.add_edge(1, 2).unwrap();
g.add_edge(0, 1).unwrap(); assert!(fluid_communities(&g, 2).is_err());
}
#[test]
fn rejects_max_iterations_zero() {
let g = k_clique(4);
let opts = FluidOptions {
seed: 0,
max_iterations: 0,
};
assert!(fluid_communities_with_options(&g, 2, &opts).is_err());
}
#[test]
fn ring_of_4_k5_cliques_finds_4_groups() {
let mut g = Graph::with_vertices(20);
for c in 0..4 {
let base = c * 5;
for u in 0..5 {
for v in (u + 1)..5 {
g.add_edge(base + u, base + v).unwrap();
}
}
let next_base = ((c + 1) % 4) * 5;
g.add_edge(base, next_base).unwrap();
}
let r = fluid_communities_with_options(
&g,
4,
&FluidOptions {
seed: 7,
max_iterations: FLUID_DEFAULT_MAX_ITERATIONS,
},
)
.unwrap();
assert_eq!(r.nb_clusters, 4);
for c in 0..4 {
let base = (c * 5) as usize;
let label = r.membership[base];
for offset in 1..5 {
assert_eq!(r.membership[base + offset], label);
}
}
}
#[test]
fn converges_in_reasonable_iterations() {
let g = two_cliques_with_bridge(6);
let r = fluid_communities_with_options(
&g,
2,
&FluidOptions {
seed: 0,
max_iterations: 100,
},
)
.unwrap();
assert!(r.n_iterations_run <= 100);
assert!(r.n_iterations_run >= 1);
}
}