#![allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::cast_sign_loss,
clippy::float_cmp,
clippy::many_single_char_names,
clippy::needless_range_loop,
clippy::ptr_arg,
clippy::too_many_arguments,
clippy::too_many_lines,
clippy::unnecessary_wraps
)]
use std::collections::VecDeque;
use crate::core::graph::EdgeId;
use crate::core::{Graph, IgraphError, IgraphResult};
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub enum LpaVariant {
Fast,
Dominance,
Retention,
}
#[derive(Debug, Clone)]
pub struct LpaOptions {
pub variant: LpaVariant,
pub initial: Option<Vec<i32>>,
pub fixed: Option<Vec<bool>>,
pub seed: u64,
}
impl Default for LpaOptions {
fn default() -> Self {
Self {
variant: LpaVariant::Fast,
initial: None,
fixed: None,
seed: 0,
}
}
}
#[derive(Debug, Clone)]
pub struct LpaResult {
pub membership: Vec<u32>,
pub nb_clusters: u32,
}
pub fn label_propagation(graph: &Graph) -> IgraphResult<LpaResult> {
label_propagation_with_options(graph, None, &LpaOptions::default())
}
pub fn label_propagation_weighted(graph: &Graph, weights: &[f64]) -> IgraphResult<LpaResult> {
label_propagation_with_options(graph, Some(weights), &LpaOptions::default())
}
pub fn label_propagation_with_options(
graph: &Graph,
weights: Option<&[f64]>,
opts: &LpaOptions,
) -> IgraphResult<LpaResult> {
if graph.is_directed() {
return Err(IgraphError::Unsupported(
"label_propagation currently supports undirected graphs only",
));
}
validate_weights(graph, weights)?;
let n = graph.vcount() as usize;
if n == 0 {
return Ok(LpaResult {
membership: Vec::new(),
nb_clusters: 0,
});
}
let mut membership: Vec<i32> = if let Some(init) = opts.initial.as_deref() {
if init.len() != n {
return Err(IgraphError::InvalidArgument(format!(
"initial labels length ({}) differs from vcount ({n})",
init.len()
)));
}
let mut max_label: i32 = -1;
for &k in init {
if k > max_label {
max_label = k;
}
}
if max_label >= 0 && i64::from(max_label) > (n as i64 - 1) {
return Err(IgraphError::InvalidArgument(format!(
"initial labels must be in [0, vcount - 1] = [0, {}]; saw {max_label}",
n - 1
)));
}
init.iter().map(|&k| if k < 0 { -1 } else { k }).collect()
} else {
(0..n).map(|i| i as i32).collect()
};
let fixed: Option<Vec<bool>> = match opts.fixed.as_deref() {
Some(f) => {
if f.len() != n {
return Err(IgraphError::InvalidArgument(format!(
"fixed mask length ({}) differs from vcount ({n})",
f.len()
)));
}
if opts.initial.is_none() {
None
} else {
let mut out = f.to_vec();
for v in 0..n {
if out[v] && membership[v] < 0 {
out[v] = false;
}
}
Some(out)
}
}
None => None,
};
let edge_weights: Vec<f64> = match weights {
Some(w) => w.to_vec(),
None => vec![1.0; graph.ecount()],
};
let adj = build_adjacency(graph, &edge_weights)?;
let mut rng = SplitMix64::new(opts.seed);
match opts.variant {
LpaVariant::Fast => {
lpa_fast(&adj, &mut membership, fixed.as_deref(), &mut rng);
}
LpaVariant::Dominance => {
lpa_dominance_or_retention(
&adj,
&mut membership,
fixed.as_deref(),
false,
&mut rng,
);
}
LpaVariant::Retention => {
lpa_dominance_or_retention(
&adj,
&mut membership,
fixed.as_deref(),
true,
&mut rng,
);
}
}
let nb_clusters = finalize_labels(graph, &mut membership, fixed.as_deref(), &mut rng)?;
let membership: Vec<u32> = membership.iter().map(|&k| k as u32).collect();
Ok(LpaResult {
membership,
nb_clusters,
})
}
fn build_adjacency(graph: &Graph, edge_weights: &[f64]) -> IgraphResult<Vec<Vec<(u32, f64)>>> {
let n = graph.vcount() as usize;
let m = graph.ecount();
let mut adj: Vec<Vec<(u32, f64)>> = vec![Vec::new(); n];
for e in 0..m {
let e_id = u32::try_from(e)
.map_err(|_| IgraphError::InvalidArgument("edge count exceeds u32::MAX".into()))?;
let (u, v) = graph.edge(e_id as EdgeId)?;
let w = edge_weights[e];
adj[u as usize].push((v, w));
if u != v {
adj[v as usize].push((u, w));
}
}
Ok(adj)
}
fn lpa_fast(
adj: &[Vec<(u32, f64)>],
membership: &mut [i32],
fixed: Option<&[bool]>,
rng: &mut SplitMix64,
) {
let n = adj.len();
if n == 0 {
return;
}
let mut label_weight = vec![0.0_f64; n];
let mut touched: Vec<i32> = Vec::with_capacity(8);
let mut dominant: Vec<i32> = Vec::with_capacity(2);
let mut order: Vec<u32> = match fixed {
Some(mask) => (0..n as u32).filter(|&v| !mask[v as usize]).collect(),
None => (0..n as u32).collect(),
};
shuffle_in_place(&mut order, rng);
let mut queue: VecDeque<u32> = order.iter().copied().collect();
let mut in_queue = vec![false; n];
for &v in &order {
in_queue[v as usize] = true;
}
while let Some(v1) = queue.pop_front() {
in_queue[v1 as usize] = false;
let neighbours = &adj[v1 as usize];
let mut max_count: f64 = 0.0;
dominant.clear();
touched.clear();
for &(v2, w) in neighbours {
let k = membership[v2 as usize];
if k < 0 {
continue;
}
let was_zero = label_weight[k as usize] == 0.0;
label_weight[k as usize] += w;
if was_zero {
touched.push(k);
}
let lw = label_weight[k as usize];
if max_count < lw {
max_count = lw;
dominant.clear();
dominant.push(k);
} else if (max_count - lw).abs() < f64::EPSILON || max_count == lw {
dominant.push(k);
}
}
if !dominant.is_empty() {
let current = membership[v1 as usize];
let pick = rng.gen_index(dominant.len());
let new_label = dominant[pick];
if new_label != current {
for &(v2, _) in neighbours {
let v2u = v2 as usize;
if in_queue[v2u] {
continue;
}
let neigh_label = membership[v2u];
let is_fixed = fixed.is_some_and(|m| m[v2u]);
if neigh_label != new_label && !is_fixed {
queue.push_back(v2);
in_queue[v2u] = true;
}
}
}
membership[v1 as usize] = new_label;
}
for &k in &touched {
label_weight[k as usize] = 0.0;
}
}
}
fn lpa_dominance_or_retention(
adj: &[Vec<(u32, f64)>],
membership: &mut [i32],
fixed: Option<&[bool]>,
retention: bool,
rng: &mut SplitMix64,
) {
let n = adj.len();
if n == 0 {
return;
}
let mut label_weight = vec![0.0_f64; n];
let mut touched: Vec<i32> = Vec::with_capacity(8);
let mut dominant: Vec<i32> = Vec::with_capacity(2);
let mut order: Vec<u32> = match fixed {
Some(mask) => (0..n as u32).filter(|&v| !mask[v as usize]).collect(),
None => (0..n as u32).collect(),
};
let mut running = true;
let mut control_iteration = true;
while running {
if retention {
running = false;
shuffle_in_place(&mut order, rng);
} else if control_iteration {
running = false;
} else {
shuffle_in_place(&mut order, rng);
}
for &v1 in &order {
let neighbours = &adj[v1 as usize];
let mut max_count: f64 = 0.0;
dominant.clear();
touched.clear();
for &(v2, w) in neighbours {
let k = membership[v2 as usize];
if k < 0 {
continue;
}
let was_zero = label_weight[k as usize] == 0.0;
label_weight[k as usize] += w;
if was_zero {
touched.push(k);
}
let lw = label_weight[k as usize];
if max_count < lw {
max_count = lw;
dominant.clear();
dominant.push(k);
} else if max_count == lw {
dominant.push(k);
}
}
if !dominant.is_empty() {
if retention {
let current = membership[v1 as usize];
let keep_current = current >= 0
&& (current as usize) < n
&& label_weight[current as usize] >= max_count;
if !keep_current {
let pick = rng.gen_index(dominant.len());
let new_label = dominant[pick];
if new_label != current {
running = true;
}
membership[v1 as usize] = new_label;
}
} else if control_iteration {
let current = membership[v1 as usize];
let still_dominant = current >= 0
&& (current as usize) < n
&& label_weight[current as usize] >= max_count;
if !still_dominant {
running = true;
}
} else {
let pick = rng.gen_index(dominant.len());
membership[v1 as usize] = dominant[pick];
}
}
for &k in &touched {
label_weight[k as usize] = 0.0;
}
}
if !retention {
control_iteration = !control_iteration;
}
}
}
fn finalize_labels(
graph: &Graph,
membership: &mut [i32],
fixed: Option<&[bool]>,
rng: &mut SplitMix64,
) -> IgraphResult<u32> {
let n = membership.len();
let mut max_seen: i32 = -1;
for &k in membership.iter() {
if k > max_seen {
max_seen = k;
}
}
let span = max_seen.max(-1) + 1;
let mut relabel: Vec<i32> = vec![-1; span.max(0) as usize];
let mut next_label: i32 = 0;
let mut unlabelled_left = false;
for v in 0..n {
let k = membership[v];
if k >= 0 {
let slot = k as usize;
if relabel[slot] == -1 {
relabel[slot] = next_label;
membership[v] = next_label;
next_label += 1;
} else {
membership[v] = relabel[slot];
}
} else {
unlabelled_left = true;
}
}
if unlabelled_left {
let mut order: Vec<u32> = match fixed {
Some(mask) => (0..n as u32).filter(|&v| !mask[v as usize]).collect(),
None => (0..n as u32).collect(),
};
shuffle_in_place(&mut order, rng);
let mut bfs_queue: VecDeque<u32> = VecDeque::new();
for &seed in &order {
if membership[seed as usize] >= 0 {
continue;
}
let label = next_label;
next_label += 1;
membership[seed as usize] = label;
bfs_queue.push_back(seed);
while let Some(v) = bfs_queue.pop_front() {
let neighbours = graph.neighbors(v)?;
for n_v in neighbours {
if membership[n_v as usize] < 0 {
membership[n_v as usize] = label;
bfs_queue.push_back(n_v);
}
}
}
}
}
Ok(next_label as u32)
}
fn validate_weights(graph: &Graph, weights: Option<&[f64]>) -> IgraphResult<()> {
let Some(w) = weights else {
return Ok(());
};
let m = graph.ecount();
if w.len() != m {
return Err(IgraphError::InvalidArgument(format!(
"weights length ({}) differs from edge count ({m})",
w.len()
)));
}
for (e, &v) in w.iter().enumerate() {
if !v.is_finite() {
return Err(IgraphError::InvalidArgument(format!(
"weight at edge {e} is not finite ({v})"
)));
}
if v < 0.0 {
return Err(IgraphError::InvalidArgument(format!(
"weight at edge {e} is negative ({v}); label_propagation requires non-negative weights"
)));
}
}
Ok(())
}
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).expect("bound fits in usize by construction")
}
}
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 add_edges(g: &mut Graph, edges: &[(u32, u32)]) {
for &(u, v) in edges {
g.add_edge(u, v).unwrap();
}
}
fn assert_dense_labels(r: &LpaResult, n: usize) {
assert_eq!(r.membership.len(), n);
if n == 0 {
assert_eq!(r.nb_clusters, 0);
return;
}
let max = *r.membership.iter().max().unwrap_or(&0);
assert!((max as usize) < n);
assert_eq!(max + 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 empty_graph_returns_empty_membership() {
let g = Graph::with_vertices(0);
let r = label_propagation(&g).unwrap();
assert_eq!(r.membership.len(), 0);
assert_eq!(r.nb_clusters, 0);
}
#[test]
fn isolated_vertices_each_get_own_label() {
let g = Graph::with_vertices(4);
let r = label_propagation(&g).unwrap();
assert_dense_labels(&r, 4);
assert_eq!(r.nb_clusters, 4);
}
#[test]
fn two_triangles_bridge_split() {
let mut g = Graph::with_vertices(6);
add_edges(
&mut g,
&[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
);
let r = label_propagation(&g).unwrap();
assert_eq!(r.membership[0], r.membership[1]);
assert_eq!(r.membership[0], r.membership[2]);
assert_eq!(r.membership[3], r.membership[4]);
assert_eq!(r.membership[3], r.membership[5]);
assert_dense_labels(&r, 6);
}
#[test]
fn directed_input_rejected() {
let mut g = Graph::new(3, true).unwrap();
g.add_edge(0, 1).unwrap();
assert!(label_propagation(&g).is_err());
assert!(label_propagation_weighted(&g, &[1.0]).is_err());
assert!(label_propagation_with_options(&g, None, &LpaOptions::default()).is_err());
}
#[test]
fn weight_validation() {
let mut g = Graph::with_vertices(3);
g.add_edge(0, 1).unwrap();
g.add_edge(1, 2).unwrap();
assert!(label_propagation_weighted(&g, &[1.0]).is_err());
assert!(label_propagation_weighted(&g, &[1.0, f64::NAN]).is_err());
assert!(label_propagation_weighted(&g, &[1.0, -0.1]).is_err());
}
#[test]
fn determinism_under_seed_fast() {
let mut g = Graph::with_vertices(8);
for &(u, v) in &[
(0, 1),
(0, 2),
(1, 2),
(2, 3),
(3, 4),
(4, 5),
(4, 6),
(5, 6),
(5, 7),
] {
g.add_edge(u, v).unwrap();
}
let opts = LpaOptions {
seed: 12345,
..LpaOptions::default()
};
let a = label_propagation_with_options(&g, None, &opts).unwrap();
let b = label_propagation_with_options(&g, None, &opts).unwrap();
assert_eq!(a.membership, b.membership);
}
#[test]
fn determinism_under_seed_dominance() {
let mut g = Graph::with_vertices(6);
add_edges(
&mut g,
&[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
);
let opts = LpaOptions {
variant: LpaVariant::Dominance,
seed: 99,
..LpaOptions::default()
};
let a = label_propagation_with_options(&g, None, &opts).unwrap();
let b = label_propagation_with_options(&g, None, &opts).unwrap();
assert_eq!(a.membership, b.membership);
}
#[test]
fn determinism_under_seed_retention() {
let mut g = Graph::with_vertices(6);
add_edges(
&mut g,
&[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
);
let opts = LpaOptions {
variant: LpaVariant::Retention,
seed: 7,
..LpaOptions::default()
};
let a = label_propagation_with_options(&g, None, &opts).unwrap();
let b = label_propagation_with_options(&g, None, &opts).unwrap();
assert_eq!(a.membership, b.membership);
}
#[test]
fn unit_weights_match_unweighted() {
let mut g = Graph::with_vertices(6);
add_edges(
&mut g,
&[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)],
);
let opts = LpaOptions {
seed: 42,
..LpaOptions::default()
};
let a = label_propagation_with_options(&g, None, &opts).unwrap();
let ones = vec![1.0; g.ecount()];
let b = label_propagation_with_options(&g, Some(&ones), &opts).unwrap();
assert_eq!(a.membership, b.membership);
}
#[test]
fn initial_validation() {
let mut g = Graph::with_vertices(3);
g.add_edge(0, 1).unwrap();
let opts = LpaOptions {
initial: Some(vec![0, 1]),
..LpaOptions::default()
};
assert!(label_propagation_with_options(&g, None, &opts).is_err());
let opts = LpaOptions {
initial: Some(vec![0, 1, 99]),
..LpaOptions::default()
};
assert!(label_propagation_with_options(&g, None, &opts).is_err());
}
#[test]
fn fixed_validation() {
let mut g = Graph::with_vertices(3);
g.add_edge(0, 1).unwrap();
let opts = LpaOptions {
initial: Some(vec![0, 1, 2]),
fixed: Some(vec![false, false]),
..LpaOptions::default()
};
assert!(label_propagation_with_options(&g, None, &opts).is_err());
}
#[test]
fn fixed_vertices_keep_their_labels() {
let mut g = Graph::with_vertices(3);
add_edges(&mut g, &[(0, 1), (0, 2), (1, 2)]);
let opts = LpaOptions {
initial: Some(vec![0, 0, 1]),
fixed: Some(vec![true, true, true]),
..LpaOptions::default()
};
let r = label_propagation_with_options(&g, None, &opts).unwrap();
assert_eq!(r.membership.len(), 3);
assert_eq!(r.membership[0], r.membership[1]);
assert_ne!(r.membership[0], r.membership[2]);
}
#[test]
fn unlabelled_component_gets_fresh_label() {
let mut g = Graph::with_vertices(6);
add_edges(&mut g, &[(0, 1), (2, 3), (4, 5)]);
let opts = LpaOptions {
initial: Some(vec![0, 0, -1, -1, -1, -1]),
..LpaOptions::default()
};
let r = label_propagation_with_options(&g, None, &opts).unwrap();
assert_eq!(r.nb_clusters, 3);
assert_eq!(r.membership[0], r.membership[1]);
assert_eq!(r.membership[2], r.membership[3]);
assert_eq!(r.membership[4], r.membership[5]);
}
#[test]
fn ignore_fixed_when_no_initial() {
let mut g = Graph::with_vertices(3);
add_edges(&mut g, &[(0, 1), (1, 2)]);
let opts = LpaOptions {
fixed: Some(vec![true, true, true]),
..LpaOptions::default()
};
let r = label_propagation_with_options(&g, None, &opts).unwrap();
assert_eq!(r.membership.len(), 3);
}
#[test]
fn self_loops_handled() {
let mut g = Graph::with_vertices(3);
add_edges(&mut g, &[(0, 0), (0, 1), (1, 2)]);
let r = label_propagation(&g).unwrap();
assert_dense_labels(&r, 3);
}
#[test]
fn three_variants_all_run_on_karate_size_input() {
let mut g = Graph::with_vertices(10);
for c in 0..2 {
let base = c * 5;
for u in 0..5 {
for v in (u + 1)..5 {
g.add_edge(base + u, base + v).unwrap();
}
}
}
g.add_edge(4, 5).unwrap();
for variant in [
LpaVariant::Fast,
LpaVariant::Dominance,
LpaVariant::Retention,
] {
let opts = LpaOptions {
variant,
seed: 0,
..LpaOptions::default()
};
let r = label_propagation_with_options(&g, None, &opts).unwrap();
assert_dense_labels(&r, 10);
for u in 0..5 {
assert_eq!(r.membership[u], r.membership[0]);
assert_eq!(r.membership[u + 5], r.membership[5]);
}
}
}
}