use crate::core::graph::EdgeId;
use crate::core::{Graph, IgraphError, IgraphResult};
pub fn modularity(graph: &Graph, membership: &[u32], resolution: f64) -> IgraphResult<Option<f64>> {
if graph.is_directed() {
return Err(IgraphError::Unsupported(
"directed modularity is ALGO-CO-001b; not yet ported",
));
}
let n = graph.vcount() as usize;
if membership.len() != n {
return Err(IgraphError::InvalidArgument(
"membership vector size differs from number of vertices".to_string(),
));
}
if !resolution.is_finite() || resolution < 0.0 {
return Err(IgraphError::InvalidArgument(
"resolution parameter must be non-negative and finite".to_string(),
));
}
let ecount = graph.ecount();
if ecount == 0 {
return Ok(None);
}
let max_label = membership.iter().copied().max().unwrap_or(0);
let mut remap: Vec<Option<u32>> = vec![None; max_label as usize + 1];
let mut next_id: u32 = 0;
let mut reindexed: Vec<u32> = Vec::with_capacity(n);
for &lbl in membership {
let slot = lbl as usize;
if remap[slot].is_none() {
remap[slot] = Some(next_id);
next_id += 1;
}
let Some(id) = remap[slot] else {
return Err(IgraphError::Internal(
"membership reindex failed: missing label",
));
};
reindexed.push(id);
}
let no_of_partitions = next_id as usize;
let mut k_part = vec![0.0_f64; no_of_partitions];
let mut e_internal = 0.0_f64;
let m_u32 =
u32::try_from(ecount).map_err(|_| IgraphError::Internal("ecount exceeds u32::MAX"))?;
for eid in 0..m_u32 {
let (src, dst) = graph.edge(eid as EdgeId)?;
let cu = reindexed[src as usize];
let cv = reindexed[dst as usize];
if cu == cv {
e_internal += 2.0;
}
k_part[cu as usize] += 1.0;
k_part[cv as usize] += 1.0;
}
#[allow(clippy::cast_precision_loss)]
let m_f = ecount as f64;
let two_m = 2.0 * m_f;
for slot in &mut k_part {
*slot /= two_m;
}
let e_norm = e_internal / two_m;
let mut q = e_norm;
for &kc in &k_part {
q -= resolution * kc * kc;
}
Ok(Some(q))
}
pub fn modularity_weighted(
graph: &Graph,
membership: &[u32],
resolution: f64,
weights: &[f64],
) -> IgraphResult<Option<f64>> {
if graph.is_directed() {
return Err(IgraphError::Unsupported(
"directed weighted modularity is ALGO-CO-001b/c follow-up; not yet ported",
));
}
let n = graph.vcount() as usize;
if membership.len() != n {
return Err(IgraphError::InvalidArgument(
"membership vector size differs from number of vertices".to_string(),
));
}
if !resolution.is_finite() || resolution < 0.0 {
return Err(IgraphError::InvalidArgument(
"resolution parameter must be non-negative and finite".to_string(),
));
}
let ecount = graph.ecount();
if weights.len() != ecount {
return Err(IgraphError::InvalidArgument(format!(
"weights vector size ({}) differs from edge count ({})",
weights.len(),
ecount
)));
}
for (e, &w) in weights.iter().enumerate() {
if w.is_nan() {
return Err(IgraphError::InvalidArgument(format!(
"weight at edge {e} is NaN"
)));
}
if w < 0.0 {
return Err(IgraphError::InvalidArgument(format!(
"weight at edge {e} is negative ({w}); modularity is undefined"
)));
}
if !w.is_finite() {
return Err(IgraphError::InvalidArgument(format!(
"weight at edge {e} is not finite ({w})"
)));
}
}
if ecount == 0 {
return Ok(None);
}
let max_label = membership.iter().copied().max().unwrap_or(0);
let mut remap: Vec<Option<u32>> = vec![None; max_label as usize + 1];
let mut next_id: u32 = 0;
let mut reindexed: Vec<u32> = Vec::with_capacity(n);
for &lbl in membership {
let slot = lbl as usize;
if remap[slot].is_none() {
remap[slot] = Some(next_id);
next_id += 1;
}
let Some(id) = remap[slot] else {
return Err(IgraphError::Internal(
"membership reindex failed: missing label",
));
};
reindexed.push(id);
}
let no_of_partitions = next_id as usize;
let mut s_part = vec![0.0_f64; no_of_partitions];
let mut w_internal = 0.0_f64;
let mut total_w = 0.0_f64;
let m_u32 =
u32::try_from(ecount).map_err(|_| IgraphError::Internal("ecount exceeds u32::MAX"))?;
for eid in 0..m_u32 {
let (src, tgt) = graph.edge(eid as EdgeId)?;
let w = weights[eid as usize];
let cu = reindexed[src as usize];
let cv = reindexed[tgt as usize];
if cu == cv {
w_internal += 2.0 * w;
}
s_part[cu as usize] += w;
s_part[cv as usize] += w;
total_w += w;
}
if total_w == 0.0 {
return Ok(None);
}
let two_w = 2.0 * total_w;
for slot in &mut s_part {
*slot /= two_w;
}
let e_norm = w_internal / two_w;
let mut q = e_norm;
for &sc in &s_part {
q -= resolution * sc * sc;
}
Ok(Some(q))
}
pub fn modularity_directed(
graph: &Graph,
membership: &[u32],
resolution: f64,
) -> IgraphResult<Option<f64>> {
if !graph.is_directed() {
return modularity(graph, membership, resolution);
}
let n = graph.vcount() as usize;
if membership.len() != n {
return Err(IgraphError::InvalidArgument(
"membership vector size differs from number of vertices".to_string(),
));
}
if !resolution.is_finite() || resolution < 0.0 {
return Err(IgraphError::InvalidArgument(
"resolution parameter must be non-negative and finite".to_string(),
));
}
let ecount = graph.ecount();
if ecount == 0 {
return Ok(None);
}
let max_label = membership.iter().copied().max().unwrap_or(0);
let mut remap: Vec<Option<u32>> = vec![None; max_label as usize + 1];
let mut next_id: u32 = 0;
let mut reindexed: Vec<u32> = Vec::with_capacity(n);
for &lbl in membership {
let slot = lbl as usize;
if remap[slot].is_none() {
remap[slot] = Some(next_id);
next_id += 1;
}
let Some(id) = remap[slot] else {
return Err(IgraphError::Internal(
"membership reindex failed: missing label",
));
};
reindexed.push(id);
}
let no_of_partitions = next_id as usize;
let mut k_out = vec![0.0_f64; no_of_partitions];
let mut k_in = vec![0.0_f64; no_of_partitions];
let mut e_internal = 0.0_f64;
let m_u32 =
u32::try_from(ecount).map_err(|_| IgraphError::Internal("ecount exceeds u32::MAX"))?;
for eid in 0..m_u32 {
let (src, tgt) = graph.edge(eid as EdgeId)?;
let cu = reindexed[src as usize];
let cv = reindexed[tgt as usize];
if cu == cv {
e_internal += 1.0;
}
k_out[cu as usize] += 1.0;
k_in[cv as usize] += 1.0;
}
#[allow(clippy::cast_precision_loss)]
let m_f = ecount as f64;
for slot in &mut k_out {
*slot /= m_f;
}
for slot in &mut k_in {
*slot /= m_f;
}
let e_norm = e_internal / m_f;
let mut q = e_norm;
for c in 0..no_of_partitions {
q -= resolution * k_out[c] * k_in[c];
}
Ok(Some(q))
}
pub fn modularity_weighted_directed(
graph: &Graph,
membership: &[u32],
resolution: f64,
weights: &[f64],
) -> IgraphResult<Option<f64>> {
if !graph.is_directed() {
return modularity_weighted(graph, membership, resolution, weights);
}
let n = graph.vcount() as usize;
if membership.len() != n {
return Err(IgraphError::InvalidArgument(
"membership vector size differs from number of vertices".to_string(),
));
}
if !resolution.is_finite() || resolution < 0.0 {
return Err(IgraphError::InvalidArgument(
"resolution parameter must be non-negative and finite".to_string(),
));
}
let ecount = graph.ecount();
if weights.len() != ecount {
return Err(IgraphError::InvalidArgument(format!(
"weights vector size ({}) differs from edge count ({})",
weights.len(),
ecount
)));
}
for (e, &w) in weights.iter().enumerate() {
if w.is_nan() {
return Err(IgraphError::InvalidArgument(format!(
"weight at edge {e} is NaN"
)));
}
if w < 0.0 {
return Err(IgraphError::InvalidArgument(format!(
"weight at edge {e} is negative ({w}); modularity is undefined"
)));
}
if !w.is_finite() {
return Err(IgraphError::InvalidArgument(format!(
"weight at edge {e} is not finite ({w})"
)));
}
}
if ecount == 0 {
return Ok(None);
}
let max_label = membership.iter().copied().max().unwrap_or(0);
let mut remap: Vec<Option<u32>> = vec![None; max_label as usize + 1];
let mut next_id: u32 = 0;
let mut reindexed: Vec<u32> = Vec::with_capacity(n);
for &lbl in membership {
let slot = lbl as usize;
if remap[slot].is_none() {
remap[slot] = Some(next_id);
next_id += 1;
}
let Some(id) = remap[slot] else {
return Err(IgraphError::Internal(
"membership reindex failed: missing label",
));
};
reindexed.push(id);
}
let no_of_partitions = next_id as usize;
let mut s_out = vec![0.0_f64; no_of_partitions];
let mut s_in = vec![0.0_f64; no_of_partitions];
let mut w_internal = 0.0_f64;
let mut total_w = 0.0_f64;
let m_u32 =
u32::try_from(ecount).map_err(|_| IgraphError::Internal("ecount exceeds u32::MAX"))?;
for eid in 0..m_u32 {
let (src, tgt) = graph.edge(eid as EdgeId)?;
let w = weights[eid as usize];
let cu = reindexed[src as usize];
let cv = reindexed[tgt as usize];
if cu == cv {
w_internal += w;
}
s_out[cu as usize] += w;
s_in[cv as usize] += w;
total_w += w;
}
if total_w == 0.0 {
return Ok(None);
}
for slot in &mut s_out {
*slot /= total_w;
}
for slot in &mut s_in {
*slot /= total_w;
}
let e_norm = w_internal / total_w;
let mut q = e_norm;
for c in 0..no_of_partitions {
q -= resolution * s_out[c] * s_in[c];
}
Ok(Some(q))
}
#[cfg(test)]
mod tests {
use super::*;
fn close(actual: f64, expected: f64, tol: f64) {
assert!(
(actual - expected).abs() < tol,
"actual={actual} expected={expected}"
);
}
#[test]
fn empty_graph_yields_none() {
let g = Graph::with_vertices(0);
let q = modularity(&g, &[], 1.0).unwrap();
assert!(q.is_none());
}
#[test]
fn no_edges_yields_none() {
let g = Graph::with_vertices(3);
let q = modularity(&g, &[0, 0, 0], 1.0).unwrap();
assert!(q.is_none());
}
#[test]
fn single_partition_zero_for_well_separated_clusters() {
let mut g = Graph::with_vertices(6);
for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
g.add_edge(u, v).unwrap();
}
let q = modularity(&g, &[0; 6], 1.0).unwrap().unwrap();
close(q, 0.0, 1e-12);
}
#[test]
fn k3_union_k3_with_bridge_two_communities_high_q() {
let mut g = Graph::with_vertices(6);
for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
g.add_edge(u, v).unwrap();
}
let q = modularity(&g, &[0, 0, 0, 1, 1, 1], 1.0).unwrap().unwrap();
close(q, 6.0_f64 / 7.0 - 0.5, 1e-12);
}
#[test]
fn negative_q_for_singleton_cluster_in_dense_graph() {
let mut g = Graph::with_vertices(4);
for u in 0..4u32 {
for v in (u + 1)..4 {
g.add_edge(u, v).unwrap();
}
}
let q = modularity(&g, &[0, 1, 2, 3], 1.0).unwrap().unwrap();
close(q, -0.25, 1e-12);
}
#[test]
fn membership_size_mismatch_errors() {
let mut g = Graph::with_vertices(3);
g.add_edge(0, 1).unwrap();
assert!(modularity(&g, &[0, 0], 1.0).is_err());
}
#[test]
fn negative_resolution_errors() {
let mut g = Graph::with_vertices(3);
g.add_edge(0, 1).unwrap();
assert!(modularity(&g, &[0, 0, 0], -0.1).is_err());
}
#[test]
fn directed_returns_unsupported() {
let mut g = Graph::new(2, true).unwrap();
g.add_edge(0, 1).unwrap();
assert!(modularity(&g, &[0, 0], 1.0).is_err());
}
#[test]
fn non_consecutive_labels_reindex_correctly() {
let mut g = Graph::with_vertices(6);
for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
g.add_edge(u, v).unwrap();
}
let q1 = modularity(&g, &[0, 0, 0, 1, 1, 1], 1.0).unwrap().unwrap();
let q2 = modularity(&g, &[7, 7, 7, 42, 42, 42], 1.0)
.unwrap()
.unwrap();
close(q1, q2, 1e-12);
}
#[test]
fn resolution_zero_yields_density_term_only() {
let mut g = Graph::with_vertices(4);
for u in 0..4u32 {
for v in (u + 1)..4 {
g.add_edge(u, v).unwrap();
}
}
let q = modularity(&g, &[0, 0, 1, 1], 0.0).unwrap().unwrap();
close(q, 4.0 / 12.0, 1e-12);
}
#[test]
fn weighted_unit_weights_match_unweighted() {
let mut g = Graph::with_vertices(6);
for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
g.add_edge(u, v).unwrap();
}
let mem = [0, 0, 0, 1, 1, 1];
let weights = vec![1.0_f64; 7];
let qw = modularity_weighted(&g, &mem, 1.0, &weights)
.unwrap()
.unwrap();
let q = modularity(&g, &mem, 1.0).unwrap().unwrap();
close(qw, q, 1e-12);
}
#[test]
fn weighted_balanced_heavy_internal_edges_increase_q() {
let mut g = Graph::with_vertices(6);
for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
g.add_edge(u, v).unwrap();
}
let mem = [0, 0, 0, 1, 1, 1];
let weights = vec![10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 0.1];
let qw = modularity_weighted(&g, &mem, 1.0, &weights)
.unwrap()
.unwrap();
let q_unit = modularity(&g, &mem, 1.0).unwrap().unwrap();
assert!(
qw > q_unit,
"balanced-heavy Q ({qw}) should exceed unit-weight Q ({q_unit})"
);
}
#[test]
fn weighted_zero_total_weight_yields_none() {
let mut g = Graph::with_vertices(2);
g.add_edge(0, 1).unwrap();
let q = modularity_weighted(&g, &[0, 0], 1.0, &[0.0]).unwrap();
assert!(q.is_none());
}
#[test]
fn weighted_negative_weight_errors() {
let mut g = Graph::with_vertices(2);
g.add_edge(0, 1).unwrap();
assert!(modularity_weighted(&g, &[0, 0], 1.0, &[-1.0]).is_err());
}
#[test]
fn weighted_size_mismatch_errors() {
let mut g = Graph::with_vertices(2);
g.add_edge(0, 1).unwrap();
assert!(modularity_weighted(&g, &[0, 0], 1.0, &[1.0, 2.0]).is_err());
}
#[test]
fn weighted_directed_returns_unsupported() {
let mut g = Graph::new(2, true).unwrap();
g.add_edge(0, 1).unwrap();
assert!(modularity_weighted(&g, &[0, 0], 1.0, &[1.0]).is_err());
}
#[test]
fn directed_two_triangles_with_bridge_high_q() {
let mut g = Graph::new(6, true).unwrap();
for &(u, v) in &[(0u32, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3), (2, 3)] {
g.add_edge(u, v).unwrap();
}
let q = modularity_directed(&g, &[0, 0, 0, 1, 1, 1], 1.0)
.unwrap()
.unwrap();
close(q, 18.0 / 49.0, 1e-12);
}
#[test]
fn directed_undirected_graph_routes_to_undirected_formula() {
let mut g = Graph::with_vertices(6);
for &(u, v) in &[(0, 1), (0, 2), (1, 2), (3, 4), (3, 5), (4, 5), (2, 3)] {
g.add_edge(u, v).unwrap();
}
let mem = [0u32, 0, 0, 1, 1, 1];
let a = modularity(&g, &mem, 1.0).unwrap();
let b = modularity_directed(&g, &mem, 1.0).unwrap();
assert_eq!(a, b);
}
#[test]
fn directed_no_edges_yields_none() {
let g = Graph::new(3, true).unwrap();
let q = modularity_directed(&g, &[0, 0, 0], 1.0).unwrap();
assert!(q.is_none());
}
#[test]
fn directed_membership_size_mismatch_errors() {
let mut g = Graph::new(3, true).unwrap();
g.add_edge(0, 1).unwrap();
assert!(modularity_directed(&g, &[0, 0], 1.0).is_err());
}
#[test]
fn directed_negative_resolution_errors() {
let mut g = Graph::new(3, true).unwrap();
g.add_edge(0, 1).unwrap();
assert!(modularity_directed(&g, &[0, 0, 0], -0.1).is_err());
}
#[test]
fn directed_3_cycle_single_partition_zero() {
let mut g = Graph::new(3, true).unwrap();
g.add_edge(0, 1).unwrap();
g.add_edge(1, 2).unwrap();
g.add_edge(2, 0).unwrap();
let q = modularity_directed(&g, &[0, 0, 0], 1.0).unwrap().unwrap();
close(q, 0.0, 1e-12);
}
#[test]
fn weighted_two_disjoint_edges_q_eq_half() {
let mut g = Graph::with_vertices(4);
g.add_edge(0, 1).unwrap();
g.add_edge(2, 3).unwrap();
let q = modularity_weighted(&g, &[0, 0, 1, 1], 1.0, &[1.0, 1.0])
.unwrap()
.unwrap();
close(q, 0.5, 1e-12);
}
}