use std::collections::{HashMap, VecDeque};
const EPS: f64 = 1e-9;
#[derive(Debug, Clone)]
pub struct WeightedGraph {
n: usize,
adj: Vec<Vec<(usize, f64)>>,
loops: Vec<f64>,
}
impl WeightedGraph {
pub fn new(n: usize) -> Self {
Self {
n,
adj: vec![Vec::new(); n],
loops: vec![0.0; n],
}
}
pub fn node_count(&self) -> usize {
self.n
}
pub fn from_edges(n: usize, edges: impl IntoIterator<Item = (usize, usize, f64)>) -> Self {
let mut aggregated: HashMap<(usize, usize), f64> = HashMap::new();
for (u, v, w) in edges {
if u >= n || v >= n || !w.is_finite() || w <= 0.0 {
continue;
}
let key = if u <= v { (u, v) } else { (v, u) };
*aggregated.entry(key).or_insert(0.0) += w;
}
let mut graph = Self::new(n);
for ((u, v), w) in aggregated {
if u == v {
graph.loops[u] += w;
} else {
graph.adj[u].push((v, w));
graph.adj[v].push((u, w));
}
}
for neighbors in &mut graph.adj {
neighbors.sort_by(|a, b| a.0.cmp(&b.0));
}
graph
}
fn degree(&self, node: usize) -> f64 {
let incident: f64 = self.adj[node].iter().map(|(_, w)| *w).sum();
incident + 2.0 * self.loops[node]
}
fn weight_to(&self, node: usize, labels: &[i64], label: i64) -> f64 {
self.adj[node]
.iter()
.filter(|(nb, _)| labels[*nb] == label)
.map(|(_, w)| *w)
.sum()
}
}
#[derive(Debug, Clone)]
struct Partition {
labels: Vec<i64>,
degree_sums: Vec<f64>,
}
impl Partition {
fn num_communities(&self) -> usize {
self.degree_sums.len()
}
}
fn compact_labels(raw: &[i64], degrees: &[f64]) -> Partition {
let mut remap: HashMap<i64, i64> = HashMap::new();
let mut labels = vec![-1i64; raw.len()];
let mut degree_sums = Vec::new();
for (node, &raw_label) in raw.iter().enumerate() {
let next = remap.len() as i64;
let label = *remap.entry(raw_label).or_insert(next);
labels[node] = label;
if label as usize >= degree_sums.len() {
degree_sums.push(0.0);
}
degree_sums[label as usize] += degrees[node];
}
Partition {
labels,
degree_sums,
}
}
struct Rng {
state: u64,
}
impl Rng {
fn new(seed: u64) -> Self {
Self { state: seed }
}
fn next_u64(&mut self) -> u64 {
self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn shuffle<T>(&mut self, items: &mut [T]) {
if items.len() < 2 {
return;
}
for i in (1..items.len()).rev() {
let j = (self.next_u64() % (i as u64 + 1)) as usize;
items.swap(i, j);
}
}
}
fn move_delta(
weight_to: f64,
weight_from: f64,
degree: f64,
sum_from_minus_node: f64,
sum_to: f64,
resolution: f64,
m: f64,
) -> f64 {
2.0 * (weight_to - weight_from) + resolution * degree * (sum_from_minus_node - sum_to) / m
}
fn local_moving(
graph: &WeightedGraph,
initial: &[i64],
resolution: f64,
rng: &mut Rng,
) -> Partition {
let degrees: Vec<f64> = (0..graph.n).map(|node| graph.degree(node)).collect();
let m2: f64 = degrees.iter().sum();
if m2 <= 0.0 {
return compact_labels(initial, °rees);
}
let m = m2 / 2.0;
let partition = compact_labels(initial, °rees);
let mut labels = partition.labels;
let mut degree_sums = partition.degree_sums;
let mut order: Vec<usize> = (0..graph.n).collect();
rng.shuffle(&mut order);
let mut queue: VecDeque<usize> = order.iter().copied().collect();
let mut in_queue = vec![true; graph.n];
while let Some(node) = queue.pop_front() {
in_queue[node] = false;
let current = labels[node];
let node_degree = degrees[node];
let sum_from_without_node = degree_sums[current as usize] - node_degree;
let weight_current = graph.weight_to(node, &labels, current);
let mut candidate_weight: HashMap<i64, f64> = HashMap::new();
for &(nb, w) in &graph.adj[node] {
let label = labels[nb];
*candidate_weight.entry(label).or_insert(0.0) += w;
}
let mut best_label = current;
let mut best_delta = 0.0_f64;
for (&label, &weight_to_label) in &candidate_weight {
if label == current {
continue;
}
let delta = move_delta(
weight_to_label,
weight_current,
node_degree,
sum_from_without_node,
degree_sums[label as usize],
resolution,
m,
);
if delta > best_delta + EPS
|| (delta > EPS && (delta - best_delta).abs() <= EPS && label < best_label)
{
best_delta = delta;
best_label = label;
}
}
if best_label != current {
degree_sums[current as usize] -= node_degree;
degree_sums[best_label as usize] += node_degree;
labels[node] = best_label;
for &(nb, _) in &graph.adj[node] {
if labels[nb] != best_label && !in_queue[nb] {
in_queue[nb] = true;
queue.push_back(nb);
}
}
}
}
Partition {
labels,
degree_sums,
}
}
fn refine(
graph: &WeightedGraph,
communities: &Partition,
resolution: f64,
rng: &mut Rng,
) -> Partition {
let degrees: Vec<f64> = (0..graph.n).map(|node| graph.degree(node)).collect();
let m2: f64 = degrees.iter().sum();
let m = m2 / 2.0;
let mut subset = vec![-1i64; graph.n];
let mut subset_degree_sums: Vec<f64> = Vec::new();
let mut next_subset = 0i64;
let mut community_nodes: HashMap<i64, Vec<usize>> = HashMap::new();
for (node, &label) in communities.labels.iter().enumerate() {
community_nodes.entry(label).or_default().push(node);
}
let mut community_order: Vec<i64> = community_nodes.keys().copied().collect();
community_order.sort_unstable();
for community in community_order {
let mut nodes = community_nodes.remove(&community).unwrap_or_default();
rng.shuffle(&mut nodes);
for node in nodes {
let mut candidate_weight: HashMap<i64, f64> = HashMap::new();
for &(nb, w) in &graph.adj[node] {
let s = subset[nb];
if s >= 0 && communities.labels[nb] == community {
*candidate_weight.entry(s).or_insert(0.0) += w;
}
}
let mut best_subset = -1i64;
let mut best_delta = 0.0_f64;
for (&s, &weight) in &candidate_weight {
let delta =
2.0 * weight - resolution * degrees[node] * subset_degree_sums[s as usize] / m;
if delta < -EPS {
continue;
}
if best_subset < 0
|| delta > best_delta + EPS
|| ((delta - best_delta).abs() <= EPS && s < best_subset)
{
best_delta = delta;
best_subset = s;
}
}
if best_subset < 0 {
best_subset = next_subset;
next_subset += 1;
subset_degree_sums.push(0.0);
}
subset[node] = best_subset;
subset_degree_sums[best_subset as usize] += degrees[node];
}
}
compact_labels(&subset, °rees)
}
struct Aggregated {
graph: WeightedGraph,
members: Vec<Vec<usize>>,
}
fn aggregate(graph: &WeightedGraph, refined: &Partition, members: &[Vec<usize>]) -> Aggregated {
let k = refined.num_communities();
let mut edges: HashMap<(usize, usize), f64> = HashMap::new();
for node in 0..graph.n {
let subset = refined.labels[node] as usize;
for &(nb, w) in &graph.adj[node] {
if nb > node {
let other = refined.labels[nb] as usize;
let key = if subset <= other {
(subset, other)
} else {
(other, subset)
};
*edges.entry(key).or_insert(0.0) += w;
}
}
if graph.loops[node] > 0.0 {
*edges.entry((subset, subset)).or_insert(0.0) += graph.loops[node];
}
}
let mut aggregated = WeightedGraph::new(k);
for ((u, v), w) in edges {
if u == v {
aggregated.loops[u] += w;
} else {
aggregated.adj[u].push((v, w));
aggregated.adj[v].push((u, w));
}
}
for neighbors in &mut aggregated.adj {
neighbors.sort_by(|a, b| a.0.cmp(&b.0));
}
let mut aggregated_members = vec![Vec::new(); k];
for (node, original) in members.iter().enumerate() {
aggregated_members[refined.labels[node] as usize].extend_from_slice(original);
}
Aggregated {
graph: aggregated,
members: aggregated_members,
}
}
pub fn leiden_modularity(graph: &WeightedGraph, resolution: f64, seed: u64) -> Vec<usize> {
let n = graph.n;
if n == 0 {
return Vec::new();
}
if n == 1 {
return vec![0];
}
let mut rng = Rng::new(seed);
let members: Vec<Vec<usize>> = (0..n).map(|node| vec![node]).collect();
let singleton: Vec<i64> = (0..n as i64).collect();
let mut partition = local_moving(graph, &singleton, resolution, &mut rng);
let mut current_graph = graph.clone();
let mut current_members = members;
loop {
if partition.num_communities() == current_graph.n {
break;
}
let refined = refine(¤t_graph, &partition, resolution, &mut rng);
if refined.num_communities() == partition.num_communities() {
break;
}
let aggregated = aggregate(¤t_graph, &refined, ¤t_members);
let mut initial = vec![-1i64; aggregated.graph.n];
for node in 0..current_graph.n {
initial[refined.labels[node] as usize] = partition.labels[node];
}
partition = local_moving(&aggregated.graph, &initial, resolution, &mut rng);
current_graph = aggregated.graph;
current_members = aggregated.members;
}
let mut result = vec![0usize; n];
for (aggregate_node, originals) in current_members.iter().enumerate() {
for &original in originals {
result[original] = partition.labels[aggregate_node] as usize;
}
}
let degrees: Vec<f64> = (0..n).map(|node| graph.degree(node)).collect();
let compact = compact_labels(
&result.iter().map(|&x| x as i64).collect::<Vec<_>>(),
°rees,
);
compact.labels.iter().map(|&x| x as usize).collect()
}
#[cfg(test)]
pub(crate) fn is_connected_induced(graph: &WeightedGraph, nodes: &[usize]) -> bool {
if nodes.is_empty() {
return true;
}
let in_set: std::collections::HashSet<usize> = nodes.iter().copied().collect();
let mut seen = std::collections::HashSet::new();
let mut stack = vec![nodes[0]];
seen.insert(nodes[0]);
while let Some(node) = stack.pop() {
for &(nb, _) in &graph.adj[node] {
if in_set.contains(&nb) && seen.insert(nb) {
stack.push(nb);
}
}
}
seen.len() == nodes.len()
}
#[cfg(test)]
mod tests {
use super::*;
fn graph_from(n: usize, pairs: &[(usize, usize)]) -> WeightedGraph {
let edges = pairs.iter().map(|&(u, v)| (u, v, 1.0)).collect::<Vec<_>>();
WeightedGraph::from_edges(n, edges)
}
fn push_clique(pairs: &mut Vec<(usize, usize)>, offset: usize, k: usize) {
for i in 0..k {
for j in (i + 1)..k {
pairs.push((offset + i, offset + j));
}
}
}
fn groups(labels: &[usize]) -> Vec<Vec<usize>> {
let k = labels.iter().copied().max().map(|m| m + 1).unwrap_or(0);
let mut groups = vec![Vec::new(); k];
for (node, &label) in labels.iter().enumerate() {
groups[label].push(node);
}
groups.retain(|g| !g.is_empty());
groups
}
#[test]
fn empty_and_single_node_graphs() {
let g = WeightedGraph::new(0);
assert!(leiden_modularity(&g, 1.0, 1).is_empty());
let g = WeightedGraph::new(1);
assert_eq!(leiden_modularity(&g, 1.0, 1), vec![0]);
}
#[test]
fn connected_pair_is_one_community() {
let g = graph_from(2, &[(0, 1)]);
let labels = leiden_modularity(&g, 1.0, 1);
assert_eq!(labels[0], labels[1]);
}
#[test]
fn two_disconnected_triangles_stay_separate() {
let mut pairs = Vec::new();
push_clique(&mut pairs, 0, 3);
push_clique(&mut pairs, 3, 3);
let g = graph_from(6, &pairs);
let labels = leiden_modularity(&g, 1.0, 1);
assert_ne!(labels[0], labels[3]);
assert_eq!(labels[0], labels[1]);
assert_eq!(labels[1], labels[2]);
assert_eq!(labels[3], labels[4]);
assert_eq!(labels[4], labels[5]);
}
#[test]
fn weak_bridge_does_not_merge_dense_cliques() {
let mut pairs = Vec::new();
push_clique(&mut pairs, 0, 4);
push_clique(&mut pairs, 4, 4);
pairs.push((3, 4));
let g = graph_from(8, &pairs);
let labels = leiden_modularity(&g, 1.0, 7);
let groups = groups(&labels);
assert_eq!(groups.len(), 2, "expected two communities, got {groups:?}");
for group in &groups {
assert_eq!(group.len(), 4);
assert!(is_connected_induced(&g, group));
}
}
#[test]
fn hierarchy_fixture_flat_partition_is_four_groups() {
let mut pairs = Vec::new();
push_clique(&mut pairs, 0, 3); push_clique(&mut pairs, 3, 3); push_clique(&mut pairs, 6, 8); push_clique(&mut pairs, 14, 8); pairs.push((2, 3));
pairs.push((13, 14));
let g = graph_from(22, &pairs);
let labels = leiden_modularity(&g, 1.0, 7);
let level0 = groups(&labels);
assert_eq!(level0.len(), 4, "level 0: {level0:?}");
assert_ne!(labels[0], labels[3]);
for group in &level0 {
assert!(is_connected_induced(&g, group));
}
}
#[test]
fn heavy_bridge_edge_merges_two_pairs() {
let weak = WeightedGraph::from_edges(4, vec![(0, 1, 1.0), (2, 3, 1.0)]);
assert_eq!(groups(&leiden_modularity(&weak, 1.0, 3)).len(), 2);
let strong = WeightedGraph::from_edges(4, vec![(0, 1, 1.0), (2, 3, 1.0), (1, 2, 10.0)]);
assert_eq!(groups(&leiden_modularity(&strong, 1.0, 3)).len(), 1);
}
#[test]
fn result_is_deterministic_for_a_seed() {
let mut pairs = Vec::new();
for c in 0..4 {
push_clique(&mut pairs, c * 8, 8);
}
pairs.push((6, 10));
pairs.push((20, 26));
pairs.push((1, 17));
let g = graph_from(32, &pairs);
let a = leiden_modularity(&g, 1.0, 123_456);
let b = leiden_modularity(&g, 1.0, 123_456);
assert_eq!(a, b);
for group in groups(&a) {
assert!(
is_connected_induced(&g, &group),
"disconnected community {group:?}"
);
}
}
#[test]
fn isolated_nodes_remain_singletons() {
let g = graph_from(5, &[(0, 1), (1, 2)]);
let labels = leiden_modularity(&g, 1.0, 1);
let groups = groups(&labels);
assert_eq!(groups.len(), 3);
assert!(groups.iter().any(|g| g.len() == 3));
assert_eq!(groups.iter().filter(|g| g.len() == 1).count(), 2);
}
#[test]
fn malformed_edges_are_ignored() {
let g = WeightedGraph::from_edges(
3,
vec![
(0, 1, 1.0),
(0, 9, 1.0), (1, 2, -1.0), (2, 2, 2.0), (0, 2, f64::NAN),
],
);
assert_eq!(g.node_count(), 3);
let labels = leiden_modularity(&g, 1.0, 1);
assert_eq!(labels[0], labels[1]);
assert_ne!(labels[0], labels[2]);
}
#[test]
fn rng_shuffle_is_deterministic() {
let mut a = Rng::new(99);
let mut va: Vec<usize> = (0..20).collect();
a.shuffle(&mut va);
let mut b = Rng::new(99);
let mut vb: Vec<usize> = (0..20).collect();
b.shuffle(&mut vb);
assert_eq!(va, vb);
assert_ne!(va, (0..20).collect::<Vec<_>>());
}
}