#![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
)]
use std::collections::HashSet;
use crate::core::rng::SplitMix64;
use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
const MAX_NODES: usize = if usize::BITS >= 64 {
1usize.wrapping_shl(53)
} else {
usize::MAX
};
fn check_fitness(label: &str, fitness: &[f64]) -> IgraphResult<()> {
let mut min_v = f64::INFINITY;
let mut max_v = f64::NEG_INFINITY;
for (i, &x) in fitness.iter().enumerate() {
if x.is_nan() {
return Err(IgraphError::InvalidArgument(format!(
"{label}[{i}] is NaN; static-fitness scores must be finite"
)));
}
if x < min_v {
min_v = x;
}
if x > max_v {
max_v = x;
}
}
if fitness.is_empty() {
return Ok(());
}
if min_v < 0.0 {
return Err(IgraphError::InvalidArgument(format!(
"{label} must be non-negative; got minimum {min_v}"
)));
}
if !max_v.is_finite() {
return Err(IgraphError::InvalidArgument(format!(
"{label} must be finite; got maximum {max_v}"
)));
}
Ok(())
}
fn cumulative_sum(fitness: &[f64]) -> Vec<f64> {
let mut cum = Vec::with_capacity(fitness.len());
let mut acc = 0.0_f64;
for &x in fitness {
acc += x;
cum.push(acc);
}
cum
}
fn binsearch_cum(cum: &[f64], target: f64) -> usize {
let mut lo = 0usize;
let mut hi = cum.len();
while lo < hi {
let mid = lo + (hi - lo) / 2;
if cum[mid] < target {
lo = mid + 1;
} else {
hi = mid;
}
}
if lo >= cum.len() { cum.len() - 1 } else { lo }
}
fn count_active(fitness: &[f64]) -> usize {
fitness.iter().filter(|&&x| x > 0.0).count()
}
fn max_edges(fitness_out: &[f64], fitness_in: Option<&[f64]>, loops: bool) -> f64 {
let n = fitness_out.len();
if n == 0 {
return 0.0;
}
if let Some(fin) = fitness_in {
let mut outn = 0usize;
let mut inn = 0usize;
let mut bothn = 0usize;
for i in 0..n {
let o = fitness_out[i] != 0.0;
let ii = fin[i] != 0.0;
if o {
outn += 1;
}
if ii {
inn += 1;
}
if o && ii {
bothn += 1;
}
}
let prod = outn as f64 * inn as f64;
if loops { prod } else { prod - bothn as f64 }
} else {
let active = count_active(fitness_out) as f64;
if loops {
active * (active + 1.0) / 2.0
} else {
active * (active - 1.0) / 2.0
}
}
}
pub fn static_fitness_game(
no_of_edges: u32,
fitness_out: &[f64],
fitness_in: Option<&[f64]>,
loops: bool,
multiple: bool,
seed: u64,
) -> IgraphResult<Graph> {
let n = fitness_out.len();
let directed = fitness_in.is_some();
if n > MAX_NODES {
return Err(IgraphError::InvalidArgument(format!(
"static-fitness vertex count {n} exceeds the largest exactly representable f64 integer (2^53)"
)));
}
let n_u32 = u32::try_from(n).map_err(|_| {
IgraphError::InvalidArgument(format!("static-fitness vertex count {n} exceeds u32::MAX"))
})?;
if n == 0 {
return Graph::new(0, directed);
}
if let Some(fin) = fitness_in {
if fin.len() != n {
return Err(IgraphError::InvalidArgument(format!(
"fitness_in length {} does not match fitness_out length {n}",
fin.len()
)));
}
}
check_fitness("fitness_out", fitness_out)?;
if let Some(fin) = fitness_in {
check_fitness("fitness_in", fin)?;
}
let max_e = max_edges(fitness_out, fitness_in, loops);
if max_e <= 0.0 && no_of_edges > 0 {
return Err(IgraphError::InvalidArgument(format!(
"No edges are possible with the given fitness scores, but {no_of_edges} edges were requested"
)));
}
if !multiple && f64::from(no_of_edges) > max_e {
return Err(IgraphError::InvalidArgument(format!(
"Requested {no_of_edges} simple edges but only {max_e} are possible with the given fitness scores"
)));
}
if no_of_edges == 0 {
return Graph::new(n_u32, directed);
}
let cum_out = cumulative_sum(fitness_out);
let Some(&max_out) = cum_out.last() else {
return Err(IgraphError::Internal("cum_out unexpectedly empty"));
};
let (cum_in_storage, cum_in_view, max_in): (Vec<f64>, &[f64], f64) = match fitness_in {
Some(fin) => {
let c = cumulative_sum(fin);
let Some(&m) = c.last() else {
return Err(IgraphError::Internal("cum_in unexpectedly empty"));
};
(c, &[], m)
}
None => (Vec::new(), &cum_out, max_out),
};
let cum_in: &[f64] = if directed {
&cum_in_storage
} else {
cum_in_view
};
if max_out <= 0.0 || max_in <= 0.0 {
return Graph::new(n_u32, directed);
}
let mut rng = SplitMix64::new(seed);
let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(no_of_edges as usize);
if multiple {
let mut remaining = no_of_edges;
while remaining > 0 {
let from = pick(&cum_out, max_out, &mut rng);
let to = pick(cum_in, max_in, &mut rng);
if !loops && from == to {
continue;
}
edges.push((from as VertexId, to as VertexId));
remaining -= 1;
}
} else {
let mut seen: HashSet<(u32, u32)> = HashSet::with_capacity(no_of_edges as usize);
let mut remaining = no_of_edges;
while remaining > 0 {
let from = pick(&cum_out, max_out, &mut rng);
let to = pick(cum_in, max_in, &mut rng);
if !loops && from == to {
continue;
}
let key = if directed || from <= to {
(from as u32, to as u32)
} else {
(to as u32, from as u32)
};
if !seen.insert(key) {
continue;
}
edges.push((from as VertexId, to as VertexId));
remaining -= 1;
}
}
let mut g = Graph::new(n_u32, directed)?;
g.add_edges(edges)?;
Ok(g)
}
#[inline]
fn pick(cum: &[f64], total: f64, rng: &mut SplitMix64) -> usize {
let u = rng.gen_unit();
let target = u * total;
binsearch_cum(cum, target)
}
#[allow(clippy::too_many_arguments)]
pub fn static_power_law_game(
no_of_nodes: u32,
no_of_edges: u32,
exponent_out: f64,
exponent_in: Option<f64>,
loops: bool,
multiple: bool,
finite_size_correction: bool,
seed: u64,
) -> IgraphResult<Graph> {
if exponent_out.is_nan() {
return Err(IgraphError::InvalidArgument(
"exponent_out must not be NaN".into(),
));
}
if exponent_out < 2.0 {
return Err(IgraphError::InvalidArgument(format!(
"Out-degree exponent must be >= 2, got {exponent_out}"
)));
}
if let Some(e) = exponent_in {
if e.is_nan() {
return Err(IgraphError::InvalidArgument(
"exponent_in must not be NaN".into(),
));
}
if e < 2.0 {
return Err(IgraphError::InvalidArgument(format!(
"For directed graphs the in-degree exponent must be >= 2, got {e}"
)));
}
}
let n = no_of_nodes as usize;
let directed = exponent_in.is_some();
if n == 0 {
return Graph::new(0, directed);
}
let fitness_out = build_power_law_fitness(n, exponent_out, finite_size_correction);
if let Some(e_in) = exponent_in {
let mut fitness_in = build_power_law_fitness(n, e_in, finite_size_correction);
let mut shuf_rng = SplitMix64::new(seed.wrapping_add(0xDEAD_BEEF_CAFE_BABE));
fisher_yates(&mut fitness_in, &mut shuf_rng);
static_fitness_game(
no_of_edges,
&fitness_out,
Some(&fitness_in),
loops,
multiple,
seed,
)
} else {
static_fitness_game(no_of_edges, &fitness_out, None, loops, multiple, seed)
}
}
fn build_power_law_fitness(n: usize, exponent: f64, finite_size_correction: bool) -> Vec<f64> {
let alpha = if exponent.is_finite() {
-1.0 / (exponent - 1.0)
} else {
0.0
};
let mut j = n as f64;
if finite_size_correction && alpha < -0.5 {
let shift = (n as f64).powf(1.0 + 0.5 / alpha)
* (10.0 * std::f64::consts::SQRT_2 * (1.0 + alpha)).powf(-1.0 / alpha)
- 1.0;
j += shift;
}
if j < n as f64 {
j = n as f64;
}
let mut fitness = Vec::with_capacity(n);
for _ in 0..n {
fitness.push(j.powf(alpha));
j -= 1.0;
}
fitness
}
fn fisher_yates(v: &mut [f64], rng: &mut SplitMix64) {
let n = v.len();
if n <= 1 {
return;
}
for i in (1..n).rev() {
let j = rng.gen_index(i + 1);
v.swap(i, j);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn deg(g: &Graph, n: usize) -> Vec<u32> {
let mut d = vec![0u32; n];
let m = u32::try_from(g.ecount()).expect("ecount fits in u32");
for eid in 0..m {
let (u, v) = g.edge(eid).expect("edge id in bounds");
d[u as usize] += 1;
if u != v {
d[v as usize] += 1;
}
}
d
}
fn directed_in_out(g: &Graph, n: usize) -> (Vec<u32>, Vec<u32>) {
let mut out = vec![0u32; n];
let mut din = vec![0u32; n];
let m = u32::try_from(g.ecount()).expect("ecount fits in u32");
for eid in 0..m {
let (u, v) = g.edge(eid).expect("edge id in bounds");
out[u as usize] += 1;
din[v as usize] += 1;
}
(out, din)
}
#[test]
fn zero_vertices_zero_edges_undirected() {
let g = static_fitness_game(0, &[], None, false, false, 0).unwrap();
assert_eq!(g.vcount(), 0);
assert_eq!(g.ecount(), 0);
assert!(!g.is_directed());
}
#[test]
fn zero_vertices_zero_edges_directed() {
let in_w: [f64; 0] = [];
let g = static_fitness_game(0, &[], Some(&in_w), true, true, 0).unwrap();
assert_eq!(g.vcount(), 0);
assert!(g.is_directed());
}
#[test]
fn zero_edges_returns_isolated_graph() {
let f = vec![1.0; 5];
let g = static_fitness_game(0, &f, None, false, false, 0).unwrap();
assert_eq!(g.vcount(), 5);
assert_eq!(g.ecount(), 0);
}
#[test]
fn single_vertex_no_loops_zero_edges_ok() {
let g = static_fitness_game(0, &[1.0], None, false, false, 0).unwrap();
assert_eq!(g.vcount(), 1);
assert_eq!(g.ecount(), 0);
}
#[test]
fn single_vertex_no_loops_with_edges_errors() {
let err = static_fitness_game(1, &[1.0], None, false, false, 0).unwrap_err();
let msg = format!("{err:?}");
assert!(msg.contains("No edges are possible"), "{msg}");
}
#[test]
fn single_vertex_with_loops_works() {
let g = static_fitness_game(3, &[1.0], None, true, true, 0).unwrap();
assert_eq!(g.vcount(), 1);
assert_eq!(g.ecount(), 3);
}
#[test]
fn negative_fitness_rejected() {
let f = [1.0, -0.1, 2.0];
let err = static_fitness_game(1, &f, None, false, true, 0).unwrap_err();
assert!(format!("{err:?}").contains("non-negative"));
}
#[test]
fn nan_fitness_rejected() {
let f = [1.0, f64::NAN, 2.0];
let err = static_fitness_game(1, &f, None, false, true, 0).unwrap_err();
assert!(format!("{err:?}").contains("NaN"));
}
#[test]
fn infinite_fitness_rejected() {
let f = [1.0, f64::INFINITY, 2.0];
let err = static_fitness_game(1, &f, None, false, true, 0).unwrap_err();
assert!(format!("{err:?}").contains("finite"));
}
#[test]
fn fitness_in_length_mismatch_rejected() {
let fo = [1.0, 2.0, 3.0];
let fi = [1.0, 2.0];
let err = static_fitness_game(1, &fo, Some(&fi), false, true, 0).unwrap_err();
assert!(format!("{err:?}").contains("length"));
}
#[test]
fn too_many_simple_edges_rejected() {
let f = vec![1.0; 4];
let err = static_fitness_game(7, &f, None, false, false, 0).unwrap_err();
assert!(format!("{err:?}").contains("simple edges"));
}
#[test]
fn many_multi_edges_accepted() {
let f = vec![1.0; 4];
let g = static_fitness_game(20, &f, None, false, true, 0).unwrap();
assert_eq!(g.ecount(), 20);
}
#[test]
fn exact_edge_count_simple() {
let f = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let g = static_fitness_game(7, &f, None, false, false, 11).unwrap();
assert_eq!(g.vcount(), 5);
assert_eq!(g.ecount(), 7);
assert!(!g.is_directed());
}
#[test]
fn no_self_loops_when_loops_false() {
let f = vec![1.0; 10];
let g = static_fitness_game(15, &f, None, false, true, 12).unwrap();
let m = u32::try_from(g.ecount()).unwrap();
for eid in 0..m {
let (u, v) = g.edge(eid).unwrap();
assert_ne!(u, v, "self-loop in edge {eid}");
}
}
#[test]
fn directed_round_trip_preserves_endpoints() {
let fo = vec![1.0, 2.0, 3.0, 4.0];
let fi = vec![3.0, 1.0, 2.0, 4.0];
let g = static_fitness_game(10, &fo, Some(&fi), false, true, 33).unwrap();
assert!(g.is_directed());
assert_eq!(g.ecount(), 10);
let (out, din) = directed_in_out(&g, 4);
assert_eq!(out.iter().sum::<u32>(), 10);
assert_eq!(din.iter().sum::<u32>(), 10);
}
#[test]
fn determinism_same_seed_same_graph() {
let f = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let g1 = static_fitness_game(8, &f, None, false, true, 99).unwrap();
let g2 = static_fitness_game(8, &f, None, false, true, 99).unwrap();
let m = u32::try_from(g1.ecount()).unwrap();
for eid in 0..m {
assert_eq!(g1.edge(eid).unwrap(), g2.edge(eid).unwrap());
}
}
#[test]
fn determinism_different_seed_differs() {
let f = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let g1 = static_fitness_game(8, &f, None, false, true, 1).unwrap();
let g2 = static_fitness_game(8, &f, None, false, true, 2).unwrap();
let mut e1: Vec<_> = (0..u32::try_from(g1.ecount()).unwrap())
.map(|e| g1.edge(e).unwrap())
.collect();
let mut e2: Vec<_> = (0..u32::try_from(g2.ecount()).unwrap())
.map(|e| g2.edge(e).unwrap())
.collect();
e1.sort_unstable();
e2.sort_unstable();
assert_ne!(e1, e2);
}
#[test]
fn degree_correlates_with_fitness() {
let n = 50;
let fitness: Vec<f64> = (0..n).map(|i| 1.0 + (i as f64)).collect();
let g = static_fitness_game(400, &fitness, None, false, true, 0x00C0_FFEE).unwrap();
let d = deg(&g, n);
let q = n / 4;
let bot: f64 = (0..q).map(|i| f64::from(d[i])).sum::<f64>() / (q as f64);
let top: f64 = (n - q..n).map(|i| f64::from(d[i])).sum::<f64>() / (q as f64);
assert!(
top > 2.0 * bot,
"top-quartile mean degree {top} should dominate bottom {bot}"
);
}
#[test]
fn power_law_basic_undirected() {
let g = static_power_law_game(100, 30, 2.0, None, true, true, true, 42).unwrap();
assert_eq!(g.vcount(), 100);
assert_eq!(g.ecount(), 30);
assert!(!g.is_directed());
}
#[test]
fn power_law_basic_directed() {
let g = static_power_law_game(100, 30, 2.0, Some(2.0), true, true, true, 42).unwrap();
assert_eq!(g.vcount(), 100);
assert_eq!(g.ecount(), 30);
assert!(g.is_directed());
}
#[test]
fn power_law_with_only_loops_simple() {
let g = static_power_law_game(90, 40, 2.0, None, true, false, true, 7).unwrap();
assert_eq!(g.vcount(), 90);
assert_eq!(g.ecount(), 40);
}
#[test]
fn power_law_with_only_multi() {
let g = static_power_law_game(110, 50, 2.0, None, false, true, true, 8).unwrap();
assert_eq!(g.vcount(), 110);
assert_eq!(g.ecount(), 50);
}
#[test]
fn power_law_zero_nodes() {
let g = static_power_law_game(0, 0, 2.0, Some(2.0), false, false, true, 0).unwrap();
assert_eq!(g.vcount(), 0);
assert!(g.is_directed());
}
#[test]
fn power_law_zero_edges_undirected() {
let g = static_power_law_game(10, 0, 2.0, None, false, false, true, 0).unwrap();
assert_eq!(g.vcount(), 10);
assert_eq!(g.ecount(), 0);
assert!(!g.is_directed());
}
#[test]
fn power_law_infinite_exponent_yields_uniform_fitness() {
let fitness = build_power_law_fitness(20, f64::INFINITY, false);
for &f in &fitness {
assert!((f - 1.0).abs() < 1e-12);
}
}
#[test]
fn power_law_exponent_too_low_rejected() {
let err = static_power_law_game(100, 30, 1.5, None, true, true, true, 0).unwrap_err();
assert!(format!("{err:?}").contains(">= 2"));
}
#[test]
fn power_law_in_exponent_too_low_rejected() {
let err = static_power_law_game(100, 30, 2.0, Some(0.5), true, true, true, 0).unwrap_err();
assert!(format!("{err:?}").contains(">= 2"));
}
#[test]
fn power_law_nan_exponent_rejected() {
let err = static_power_law_game(100, 30, f64::NAN, None, true, true, true, 0).unwrap_err();
assert!(format!("{err:?}").contains("NaN"));
}
#[test]
fn binsearch_cum_finds_first_ge() {
let cum = vec![1.0, 3.0, 6.0, 10.0];
assert_eq!(binsearch_cum(&cum, 0.0), 0);
assert_eq!(binsearch_cum(&cum, 0.5), 0);
assert_eq!(binsearch_cum(&cum, 1.0), 0);
assert_eq!(binsearch_cum(&cum, 1.5), 1);
assert_eq!(binsearch_cum(&cum, 3.0), 1);
assert_eq!(binsearch_cum(&cum, 5.5), 2);
assert_eq!(binsearch_cum(&cum, 10.0), 3);
}
#[test]
fn cumulative_sum_basic() {
let v = vec![1.0, 2.0, 3.0];
let c = cumulative_sum(&v);
assert_eq!(c, vec![1.0, 3.0, 6.0]);
}
#[test]
fn max_edges_undirected_no_loops() {
let f = vec![1.0; 4];
let m = max_edges(&f, None, false);
assert!((m - 6.0).abs() < 1e-12);
}
#[test]
fn max_edges_undirected_loops() {
let f = vec![1.0; 4];
let m = max_edges(&f, None, true);
assert!((m - 10.0).abs() < 1e-12);
}
#[test]
fn max_edges_directed_no_loops() {
let fo = vec![1.0; 4];
let fi = vec![1.0; 4];
let m = max_edges(&fo, Some(&fi), false);
assert!((m - 12.0).abs() < 1e-12);
}
}
#[cfg(all(test, feature = "proptest-harness"))]
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig {
cases: 64,
..ProptestConfig::default()
})]
#[test]
fn fitness_exact_edge_count(
n in 2usize..30,
seed in any::<u64>(),
edges in 1u32..20,
multiple in any::<bool>(),
loops in any::<bool>(),
) {
let f: Vec<f64> = (0..n).map(|i| 1.0 + i as f64).collect();
let cap = if loops {
(n * (n + 1) / 2) as u32
} else {
(n * (n - 1) / 2) as u32
};
let m = if multiple { edges } else { edges.min(cap.max(1)) };
if !multiple && cap == 0 {
return Ok(());
}
let g = static_fitness_game(m, &f, None, loops, multiple, seed).unwrap();
prop_assert_eq!(g.vcount(), n as u32);
prop_assert_eq!(g.ecount(), m as usize);
}
#[test]
fn fitness_no_loops_when_disabled(
n in 3usize..20,
seed in any::<u64>(),
edges in 1u32..15,
) {
let f: Vec<f64> = (0..n).map(|i| 1.0 + i as f64).collect();
let cap = (n * (n - 1) / 2) as u32;
let m = edges.min(cap);
let g = static_fitness_game(m, &f, None, false, true, seed).unwrap();
let me = u32::try_from(g.ecount()).unwrap();
for eid in 0..me {
let (u, v) = g.edge(eid).unwrap();
prop_assert_ne!(u, v);
}
}
#[test]
fn power_law_exact_edge_count(
n in 5u32..60,
edges in 1u32..40,
seed in any::<u64>(),
) {
let g = static_power_law_game(n, edges, 2.5, None, true, true, true, seed).unwrap();
prop_assert_eq!(g.vcount(), n);
prop_assert_eq!(g.ecount(), edges as usize);
}
}
}