#![allow(
unknown_lints,
clippy::cast_possible_truncation,
clippy::cast_precision_loss,
clippy::cast_sign_loss,
clippy::float_cmp,
clippy::similar_names,
clippy::many_single_char_names,
clippy::unnecessary_cast
)]
use crate::core::rng::SplitMix64;
use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChungLuVariant {
Original,
Maxent,
Nr,
}
const MAX_NODES: usize = if usize::BITS >= 64 {
1usize.wrapping_shl(53)
} else {
usize::MAX
};
fn check_weights(label: &str, weights: &[f64]) -> IgraphResult<()> {
let mut max_w = f64::NEG_INFINITY;
let mut min_w = f64::INFINITY;
for (i, &w) in weights.iter().enumerate() {
if w.is_nan() {
return Err(IgraphError::InvalidArgument(format!(
"{label}[{i}] is NaN; Chung–Lu weights must be finite"
)));
}
if w < min_w {
min_w = w;
}
if w > max_w {
max_w = w;
}
}
if weights.is_empty() {
return Ok(());
}
if min_w < 0.0 {
return Err(IgraphError::InvalidArgument(format!(
"{label} must be non-negative; got minimum {min_w}"
)));
}
if !max_w.is_finite() {
return Err(IgraphError::InvalidArgument(format!(
"{label} must be finite; got maximum {max_w}"
)));
}
Ok(())
}
fn sort_indices_desc(key: &[f64]) -> Vec<usize> {
let mut idx: Vec<usize> = (0..key.len()).collect();
idx.sort_by(|&a, &b| {
key[b]
.partial_cmp(&key[a])
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.cmp(&b))
});
idx
}
#[inline]
fn apply_variant(q: f64, variant: ChungLuVariant) -> f64 {
match variant {
ChungLuVariant::Original => {
if q > 1.0 {
1.0
} else {
q
}
}
ChungLuVariant::Maxent => q / (1.0 + q),
ChungLuVariant::Nr => 1.0 - (-q).exp(),
}
}
pub fn chung_lu_game(
out_weights: &[f64],
in_weights: Option<&[f64]>,
loops: bool,
variant: ChungLuVariant,
seed: u64,
) -> IgraphResult<Graph> {
let n = out_weights.len();
let directed = in_weights.is_some();
if n > MAX_NODES {
return Err(IgraphError::InvalidArgument(format!(
"Chung–Lu vertex count {n} exceeds the largest exactly representable f64 integer (2^53)"
)));
}
if let Some(inw) = in_weights {
if inw.len() != n {
return Err(IgraphError::InvalidArgument(format!(
"in_weights length {} does not match out_weights length {n}",
inw.len()
)));
}
}
let n_u32 = u32::try_from(n).map_err(|_| {
IgraphError::InvalidArgument(format!("Chung–Lu vertex count {n} exceeds u32::MAX"))
})?;
if n == 0 {
return Graph::new(0, directed);
}
check_weights("out_weights", out_weights)?;
let wsum: f64 = out_weights.iter().sum();
let in_view: &[f64] = match in_weights {
Some(inw) => {
check_weights("in_weights", inw)?;
let in_sum: f64 = inw.iter().sum();
if in_sum != wsum {
return Err(IgraphError::InvalidArgument(format!(
"Sum of out- and in-weights must be the same; got out_sum = {wsum} and in_sum = {in_sum}"
)));
}
inw
}
None => out_weights,
};
if wsum == 0.0 {
return Graph::new(n_u32, directed);
}
let idx = sort_indices_desc(in_view);
let mut rng = SplitMix64::new(seed);
let mut edges: Vec<(VertexId, VertexId)> = Vec::new();
for i in 0..n {
let vi = idx[i];
let wi = out_weights[vi];
if wi == 0.0 {
if directed {
continue;
}
break;
}
let mut j = if directed { 0 } else { i };
let mut p: f64 = 1.0;
loop {
let remaining = n - j;
let gap = rng.gen_geom(p);
if !gap.is_finite() || gap >= remaining as f64 {
break;
}
j += gap as usize;
if j >= n {
break;
}
let vj = idx[j];
let wj = in_view[vj];
let q_raw = wi * wj / wsum;
let q = apply_variant(q_raw, variant);
if q == 0.0 {
break;
}
let ratio = if p > 0.0 { q / p } else { 0.0 };
let accept = rng.gen_unit() < ratio;
if accept && (loops || vi != vj) {
let from = u32::try_from(vi).map_err(|_| {
IgraphError::InvalidArgument(format!(
"Chung–Lu source vertex index {vi} overflows u32"
))
})?;
let to = u32::try_from(vj).map_err(|_| {
IgraphError::InvalidArgument(format!(
"Chung–Lu target vertex index {vj} overflows u32"
))
})?;
edges.push((from, to));
}
p = q;
j += 1;
}
}
let mut g = Graph::new(n_u32, directed)?;
g.add_edges(edges)?;
Ok(g)
}
#[cfg(test)]
mod tests {
use super::*;
fn ecount(g: &Graph) -> usize {
g.ecount() as usize
}
#[test]
fn empty_weights_undirected() {
let g = chung_lu_game(&[], None, false, ChungLuVariant::Original, 0).unwrap();
assert_eq!(g.vcount(), 0);
assert!(!g.is_directed());
assert_eq!(g.ecount(), 0);
}
#[test]
fn empty_weights_directed() {
let in_w: [f64; 0] = [];
let g = chung_lu_game(&[], Some(&in_w), true, ChungLuVariant::Maxent, 0).unwrap();
assert_eq!(g.vcount(), 0);
assert!(g.is_directed());
}
#[test]
fn zero_weights_yields_empty_graph() {
let w = vec![0.0; 8];
for variant in [
ChungLuVariant::Original,
ChungLuVariant::Maxent,
ChungLuVariant::Nr,
] {
let g = chung_lu_game(&w, None, true, variant, 1).unwrap();
assert_eq!(g.vcount(), 8);
assert_eq!(g.ecount(), 0, "variant {variant:?} should give empty graph");
}
}
#[test]
fn single_vertex_no_loops_is_empty() {
let g = chung_lu_game(&[5.0], None, false, ChungLuVariant::Maxent, 7).unwrap();
assert_eq!(g.vcount(), 1);
assert_eq!(g.ecount(), 0);
}
#[test]
fn rejects_negative_weight() {
let w = vec![1.0, -0.5, 2.0];
let err = chung_lu_game(&w, None, false, ChungLuVariant::Original, 0).unwrap_err();
match err {
IgraphError::InvalidArgument(msg) => {
assert!(msg.contains("non-negative"), "msg = {msg}");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn rejects_non_finite_weight() {
let w = vec![1.0, f64::INFINITY];
let err = chung_lu_game(&w, None, false, ChungLuVariant::Original, 0).unwrap_err();
assert!(matches!(err, IgraphError::InvalidArgument(_)));
let w_nan = vec![1.0, f64::NAN];
let err = chung_lu_game(&w_nan, None, false, ChungLuVariant::Maxent, 0).unwrap_err();
assert!(matches!(err, IgraphError::InvalidArgument(_)));
}
#[test]
fn rejects_mismatched_in_out_lengths() {
let out_w = vec![1.0, 2.0, 3.0];
let in_w = vec![1.0, 5.0];
let err =
chung_lu_game(&out_w, Some(&in_w), false, ChungLuVariant::Original, 0).unwrap_err();
match err {
IgraphError::InvalidArgument(msg) => assert!(msg.contains("length"), "msg = {msg}"),
other => panic!("{other:?}"),
}
}
#[test]
fn rejects_mismatched_in_out_sums() {
let out_w = vec![1.0, 2.0, 3.0];
let in_w = vec![1.0, 1.0, 1.0];
let err = chung_lu_game(&out_w, Some(&in_w), false, ChungLuVariant::Maxent, 0).unwrap_err();
match err {
IgraphError::InvalidArgument(msg) => assert!(msg.contains("sum"), "msg = {msg}"),
other => panic!("{other:?}"),
}
}
#[test]
fn deterministic_for_fixed_seed() {
let w: Vec<f64> = (0..40).map(|i| 1.0 + f64::from(i) * 0.1).collect();
let g1 = chung_lu_game(&w, None, false, ChungLuVariant::Maxent, 0xABC).unwrap();
let g2 = chung_lu_game(&w, None, false, ChungLuVariant::Maxent, 0xABC).unwrap();
assert_eq!(ecount(&g1), ecount(&g2));
let m = ecount(&g1);
for eid in 0..m as u32 {
assert_eq!(g1.edge(eid).unwrap(), g2.edge(eid).unwrap());
}
}
#[test]
fn different_seeds_differ_or_match_size_only() {
let w: Vec<f64> = (0..40).map(|i| 1.0 + f64::from(i) * 0.1).collect();
let g1 = chung_lu_game(&w, None, false, ChungLuVariant::Maxent, 1).unwrap();
let g2 = chung_lu_game(&w, None, false, ChungLuVariant::Maxent, 2).unwrap();
assert_eq!(g1.vcount(), g2.vcount());
let m1 = ecount(&g1);
let m2 = ecount(&g2);
if m1 == m2 && m1 > 0 {
let mut any_diff = false;
for eid in 0..m1 as u32 {
if g1.edge(eid).unwrap() != g2.edge(eid).unwrap() {
any_diff = true;
break;
}
}
assert!(
any_diff,
"two seeds happened to produce identical edge sequences"
);
}
}
#[test]
fn no_loops_when_loops_false() {
let w = vec![5.0; 20];
let g = chung_lu_game(&w, None, false, ChungLuVariant::Maxent, 9).unwrap();
let m = ecount(&g) as u32;
for eid in 0..m {
let (u, v) = g.edge(eid).unwrap();
assert!(u != v, "found self-loop at edge {eid}: ({u},{v})");
}
}
#[test]
fn directed_graph_has_directed_edges() {
let w = vec![3.0; 15];
let g = chung_lu_game(&w, Some(&w), false, ChungLuVariant::Maxent, 11).unwrap();
assert!(g.is_directed());
assert_eq!(g.vcount(), 15);
}
#[test]
fn maxent_mean_degree_matches_weight_in_sparse_limit() {
let n = 200_usize;
let w_val = 4.0_f64;
let w = vec![w_val; n];
let g = chung_lu_game(&w, None, false, ChungLuVariant::Maxent, 0x5EE_D001).unwrap();
let total_deg = 2.0 * ecount(&g) as f64;
let mean_deg = total_deg / n as f64;
let expected = w_val * (n as f64 - 1.0) / (n as f64 + w_val);
let tol = 1.0_f64; assert!(
(mean_deg - expected).abs() < tol,
"mean_deg = {mean_deg}, expected ≈ {expected}"
);
}
#[test]
fn nr_and_original_all_zero_probability_yields_no_edges() {
let w = vec![1e-3; 50];
for variant in [
ChungLuVariant::Original,
ChungLuVariant::Maxent,
ChungLuVariant::Nr,
] {
let g = chung_lu_game(&w, None, false, variant, 13).unwrap();
assert!(
ecount(&g) <= 3,
"variant {variant:?} produced too many edges"
);
}
}
#[test]
fn vertex_count_is_input_length() {
for n in [1, 2, 5, 20] {
let w = vec![2.0; n];
let g = chung_lu_game(&w, None, true, ChungLuVariant::Maxent, 1).unwrap();
assert_eq!(g.vcount() as usize, n);
}
}
#[test]
fn original_with_very_large_weights_is_dense() {
let n = 30;
let w = vec![100.0; n];
let g = chung_lu_game(&w, None, false, ChungLuVariant::Original, 19).unwrap();
let m = ecount(&g);
let cmax = n * (n - 1) / 2;
assert!(
m as f64 >= 0.95 * cmax as f64,
"expected near-complete graph, got {m}/{cmax}"
);
}
}
#[cfg(all(test, feature = "proptest-harness"))]
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(48))]
#[test]
fn vcount_matches_weight_len(
n in 1usize..40,
w_max in 0.0_f64..5.0,
seed: u64,
variant_pick in 0u8..3,
) {
let weights: Vec<f64> = (0..n).map(|i| w_max * (i as f64 + 1.0) / n as f64).collect();
let variant = match variant_pick {
0 => ChungLuVariant::Original,
1 => ChungLuVariant::Maxent,
_ => ChungLuVariant::Nr,
};
let g = chung_lu_game(&weights, None, false, variant, seed).unwrap();
prop_assert_eq!(g.vcount() as usize, n);
prop_assert!(!g.is_directed());
}
#[test]
fn determinism(
n in 1usize..40,
w_max in 0.0_f64..3.0,
seed: u64,
loops: bool,
) {
let weights: Vec<f64> = (0..n).map(|i| w_max * (i as f64 + 1.0) / n as f64).collect();
let g1 = chung_lu_game(&weights, None, loops, ChungLuVariant::Maxent, seed).unwrap();
let g2 = chung_lu_game(&weights, None, loops, ChungLuVariant::Maxent, seed).unwrap();
prop_assert_eq!(g1.ecount(), g2.ecount());
let m = g1.ecount();
for eid in 0..m as u32 {
prop_assert_eq!(g1.edge(eid).unwrap(), g2.edge(eid).unwrap());
}
}
#[test]
fn no_self_loops_when_disabled(
n in 2usize..30,
w_max in 0.5_f64..4.0,
seed: u64,
) {
let weights: Vec<f64> = (0..n).map(|i| w_max * (i as f64 + 1.0) / n as f64).collect();
let g = chung_lu_game(&weights, None, false, ChungLuVariant::Maxent, seed).unwrap();
let m = g.ecount() as u32;
for eid in 0..m {
let (u, v) = g.edge(eid).unwrap();
prop_assert_ne!(u, v);
}
}
}
}