#![allow(
unknown_lints,
clippy::cast_possible_truncation,
clippy::cast_precision_loss,
clippy::cast_sign_loss,
clippy::float_cmp,
clippy::too_many_arguments,
clippy::similar_names,
clippy::manual_midpoint,
clippy::many_single_char_names
)]
use crate::algorithms::games::sbm::{PairShape, block_offsets, sample_pair_with_max};
use crate::core::rng::SplitMix64;
use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
const SUM_TOL: f64 = 1.490_116_119_384_765_6e-8;
fn validate_pref(c: &[Vec<f64>], k: usize, label: &str) -> IgraphResult<()> {
if c.len() != k {
return Err(IgraphError::InvalidArgument(format!(
"{label}: pref matrix has {} rows but rho has length {k}",
c.len()
)));
}
for (i, row) in c.iter().enumerate() {
if row.len() != k {
return Err(IgraphError::InvalidArgument(format!(
"{label}: pref matrix row {i} has length {} (expected {k})",
row.len()
)));
}
for (j, &val) in row.iter().enumerate() {
if !val.is_finite() {
return Err(IgraphError::InvalidArgument(format!(
"{label}: pref[{i}][{j}] = {val} is not finite"
)));
}
if !(0.0..=1.0).contains(&val) {
return Err(IgraphError::InvalidArgument(format!(
"{label}: pref[{i}][{j}] = {val} must lie in [0, 1]"
)));
}
}
}
for (i, row_i) in c.iter().enumerate() {
for (j, row_j) in c.iter().enumerate().skip(i + 1) {
let pij = row_i[j];
let pji = row_j[i];
if pij != pji {
return Err(IgraphError::InvalidArgument(format!(
"{label}: pref matrix is not symmetric: pref[{i}][{j}] = {pij}, \
pref[{j}][{i}] = {pji}"
)));
}
}
}
Ok(())
}
fn validate_rho(rho: &[f64], label: &str) -> IgraphResult<()> {
if rho.is_empty() {
return Err(IgraphError::InvalidArgument(format!(
"{label}: rho must contain at least one entry"
)));
}
let mut acc = 0.0;
for (i, &val) in rho.iter().enumerate() {
if !val.is_finite() {
return Err(IgraphError::InvalidArgument(format!(
"{label}: rho[{i}] = {val} is not finite"
)));
}
if !(0.0..=1.0).contains(&val) {
return Err(IgraphError::InvalidArgument(format!(
"{label}: rho[{i}] = {val} must lie in [0, 1]"
)));
}
acc += val;
}
if (acc - 1.0).abs() > SUM_TOL {
return Err(IgraphError::InvalidArgument(format!(
"{label}: sum(rho) = {acc} must equal 1 (tolerance {SUM_TOL})"
)));
}
Ok(())
}
fn csizes_for(rho: &[f64], m: u32, label: &str) -> IgraphResult<Vec<u32>> {
let m_f = f64::from(m);
let mut sizes = Vec::with_capacity(rho.len());
for (i, &r) in rho.iter().enumerate() {
let s = r * m_f;
let rounded = s.round();
if (rounded - s).abs() > SUM_TOL {
return Err(IgraphError::InvalidArgument(format!(
"{label}: rho[{i}] * m = {s} is not an integer (tolerance {SUM_TOL})"
)));
}
if rounded < 0.0 || rounded > f64::from(u32::MAX) {
return Err(IgraphError::InvalidArgument(format!(
"{label}: rho[{i}] * m = {rounded} is out of u32 range"
)));
}
sizes.push(rounded as u32);
}
let sum: u64 = sizes.iter().copied().map(u64::from).sum();
if sum != u64::from(m) {
return Err(IgraphError::InvalidArgument(format!(
"{label}: sum of round(rho[i] * m) = {sum} but m = {m}"
)));
}
Ok(sizes)
}
fn sample_intra_macro(
rng: &mut SplitMix64,
edges: &mut Vec<(VertexId, VertexId)>,
macro_off: u32,
csizes: &[u32],
c: &[Vec<f64>],
) {
let k = csizes.len();
let mut intra_off = vec![0u32; k + 1];
let mut acc = 0u32;
for (i, &s) in csizes.iter().enumerate() {
acc += s;
intra_off[i + 1] = acc;
}
for from in 0..k {
let fromsize = csizes[from];
if fromsize == 0 {
continue;
}
let fromoff = macro_off + intra_off[from];
for to in from..k {
let tosize = csizes[to];
if tosize == 0 {
continue;
}
let tooff = macro_off + intra_off[to];
let prob = c[from][to];
if prob <= 0.0 {
continue;
}
let (shape, maxedges) = if from == to {
let fs = u64::from(fromsize);
(PairShape::TriExclDiag, fs * fs.saturating_sub(1) / 2)
} else {
(PairShape::Rect, u64::from(fromsize) * u64::from(tosize))
};
sample_pair_with_max(
rng, edges, fromsize, fromoff, tooff, shape, false, prob, maxedges,
);
}
}
}
fn full_bipartite(
edges: &mut Vec<(VertexId, VertexId)>,
fromoff: u32,
fromsize: u32,
tooff: u32,
tosize: u32,
) {
for u in 0..fromsize {
for v in 0..tosize {
edges.push((fromoff + u, tooff + v));
}
}
}
fn rect_between_macros(
rng: &mut SplitMix64,
edges: &mut Vec<(VertexId, VertexId)>,
fromoff: u32,
fromsize: u32,
tooff: u32,
tosize: u32,
p: f64,
) {
if fromsize == 0 || tosize == 0 || p <= 0.0 {
return;
}
let maxedges = u64::from(fromsize) * u64::from(tosize);
sample_pair_with_max(
rng,
edges,
fromsize,
fromoff,
tooff,
PairShape::Rect,
false,
p,
maxedges,
);
}
fn add_inter_macro(
rng: &mut SplitMix64,
edges: &mut Vec<(VertexId, VertexId)>,
macro_offsets: &[u32],
macro_sizes: &[u32],
p: f64,
) {
if p <= 0.0 {
return;
}
let k = macro_sizes.len();
if p >= 1.0 {
for b1 in 0..k {
let fromoff = macro_offsets[b1];
let fromsize = macro_sizes[b1];
if fromsize == 0 {
continue;
}
for b2 in (b1 + 1)..k {
let tosize = macro_sizes[b2];
if tosize == 0 {
continue;
}
full_bipartite(edges, fromoff, fromsize, macro_offsets[b2], tosize);
}
}
return;
}
for b1 in 0..k {
let fromoff = macro_offsets[b1];
let fromsize = macro_sizes[b1];
if fromsize == 0 {
continue;
}
for b2 in (b1 + 1)..k {
let tosize = macro_sizes[b2];
if tosize == 0 {
continue;
}
rect_between_macros(rng, edges, fromoff, fromsize, macro_offsets[b2], tosize, p);
}
}
}
pub fn hsbm_game(
n: u32,
m: u32,
rho: &[f64],
c: &[Vec<f64>],
p: f64,
seed: u64,
) -> IgraphResult<Graph> {
if n == 0 {
return Err(IgraphError::InvalidArgument(
"hsbm_game: n must be at least 1".into(),
));
}
if m == 0 {
return Err(IgraphError::InvalidArgument(
"hsbm_game: m must be at least 1".into(),
));
}
if n % m != 0 {
return Err(IgraphError::InvalidArgument(format!(
"hsbm_game: n ({n}) must be a multiple of m ({m})"
)));
}
if !p.is_finite() || !(0.0..=1.0).contains(&p) {
return Err(IgraphError::InvalidArgument(format!(
"hsbm_game: p = {p} must lie in [0, 1]"
)));
}
let k = rho.len();
validate_rho(rho, "hsbm_game")?;
validate_pref(c, k, "hsbm_game")?;
let csizes = csizes_for(rho, m, "hsbm_game")?;
let no_macros = n / m;
let mut rng = SplitMix64::new(seed);
let mut edges: Vec<(VertexId, VertexId)> = Vec::new();
for b in 0..no_macros {
let macro_off = b * m;
sample_intra_macro(&mut rng, &mut edges, macro_off, &csizes, c);
}
let mut macro_offsets = Vec::with_capacity(no_macros as usize + 1);
let mut macro_sizes = Vec::with_capacity(no_macros as usize);
for b in 0..no_macros {
macro_offsets.push(b * m);
macro_sizes.push(m);
}
macro_offsets.push(no_macros * m);
add_inter_macro(&mut rng, &mut edges, ¯o_offsets, ¯o_sizes, p);
let mut g = Graph::new(n, false)?;
g.add_edges(edges)?;
Ok(g)
}
pub fn hsbm_list_game(
n: u32,
m_list: &[u32],
rho_list: &[Vec<f64>],
c_list: &[Vec<Vec<f64>>],
p: f64,
seed: u64,
) -> IgraphResult<Graph> {
if n == 0 {
return Err(IgraphError::InvalidArgument(
"hsbm_list_game: n must be at least 1".into(),
));
}
if !p.is_finite() || !(0.0..=1.0).contains(&p) {
return Err(IgraphError::InvalidArgument(format!(
"hsbm_list_game: p = {p} must lie in [0, 1]"
)));
}
let no_macros = rho_list.len();
if no_macros == 0 {
return Err(IgraphError::InvalidArgument(
"hsbm_list_game: rho_list must contain at least one entry".into(),
));
}
if m_list.len() != no_macros || c_list.len() != no_macros {
return Err(IgraphError::InvalidArgument(format!(
"hsbm_list_game: rho_list ({no_macros}), m_list ({}) and c_list ({}) \
must have the same length",
m_list.len(),
c_list.len()
)));
}
if m_list.contains(&0) {
return Err(IgraphError::InvalidArgument(
"hsbm_list_game: every m_list[b] must be at least 1".into(),
));
}
let sum_m: u64 = m_list.iter().copied().map(u64::from).sum();
if sum_m != u64::from(n) {
return Err(IgraphError::InvalidArgument(format!(
"hsbm_list_game: sum(m_list) = {sum_m} but n = {n}"
)));
}
let (macro_offsets, _total) = block_offsets(m_list)?;
let mut all_csizes: Vec<Vec<u32>> = Vec::with_capacity(no_macros);
for b in 0..no_macros {
let k = rho_list[b].len();
let label = format!("hsbm_list_game (macro {b})");
validate_rho(&rho_list[b], &label)?;
validate_pref(&c_list[b], k, &label)?;
let csizes = csizes_for(&rho_list[b], m_list[b], &label)?;
all_csizes.push(csizes);
}
let mut rng = SplitMix64::new(seed);
let mut edges: Vec<(VertexId, VertexId)> = Vec::new();
for b in 0..no_macros {
sample_intra_macro(
&mut rng,
&mut edges,
macro_offsets[b],
&all_csizes[b],
&c_list[b],
);
}
add_inter_macro(&mut rng, &mut edges, ¯o_offsets, m_list, p);
let mut g = Graph::new(n, false)?;
g.add_edges(edges)?;
Ok(g)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hsbm_rejects_n_not_multiple_of_m() {
let rho = vec![1.0];
let c = vec![vec![0.5]];
let res = hsbm_game(10, 3, &rho, &c, 0.0, 0);
assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
}
#[test]
fn hsbm_rejects_zero_n() {
let rho = vec![1.0];
let c = vec![vec![0.5]];
let res = hsbm_game(0, 1, &rho, &c, 0.0, 0);
assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
}
#[test]
fn hsbm_rejects_zero_m() {
let rho = vec![1.0];
let c = vec![vec![0.5]];
let res = hsbm_game(10, 0, &rho, &c, 0.0, 0);
assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
}
#[test]
fn hsbm_rejects_p_out_of_range() {
let rho = vec![1.0];
let c = vec![vec![0.5]];
let res = hsbm_game(10, 5, &rho, &c, 1.5, 0);
assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
}
#[test]
fn hsbm_rejects_rho_not_summing_to_one() {
let rho = vec![0.3, 0.3];
let c = vec![vec![0.5, 0.5], vec![0.5, 0.5]];
let res = hsbm_game(10, 5, &rho, &c, 0.0, 0);
assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
}
#[test]
fn hsbm_rejects_rho_times_m_not_integer() {
let rho = vec![0.5, 0.5];
let c = vec![vec![0.5, 0.5], vec![0.5, 0.5]];
let res = hsbm_game(14, 7, &rho, &c, 0.0, 0);
assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
}
#[test]
fn hsbm_rejects_asymmetric_c() {
let rho = vec![0.5, 0.5];
let c = vec![vec![0.5, 0.2], vec![0.3, 0.5]];
let res = hsbm_game(10, 5, &rho, &c, 0.0, 0);
assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
}
#[test]
fn hsbm_rejects_c_out_of_range() {
let rho = vec![1.0];
let c = vec![vec![1.5]];
let res = hsbm_game(10, 5, &rho, &c, 0.0, 0);
assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
}
#[test]
fn hsbm_rejects_pref_wrong_shape() {
let rho = vec![0.5, 0.5];
let c = vec![vec![0.5]];
let res = hsbm_game(10, 5, &rho, &c, 0.0, 0);
assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
}
#[test]
fn hsbm_trivial_one_vertex() {
let rho = vec![1.0];
let c = vec![vec![1.0]];
let g = hsbm_game(1, 1, &rho, &c, 0.0, 0).unwrap();
assert_eq!(g.vcount(), 1);
assert_eq!(g.ecount(), 0);
assert!(!g.is_directed());
}
#[test]
fn hsbm_complete_bipartite_within_macro() {
let rho = vec![0.6, 0.4, 0.0];
let c = vec![
vec![0.0, 1.0, 0.0],
vec![1.0, 0.0, 0.0],
vec![0.0, 0.0, 0.0],
];
let g = hsbm_game(10, 10, &rho, &c, 0.0, 0).unwrap();
assert_eq!(g.vcount(), 10);
assert_eq!(g.ecount(), 24);
for e in 0..g.ecount() as u32 {
let (u, v) = g.edge(e).unwrap();
let (lo, hi) = if u < v { (u, v) } else { (v, u) };
assert!(lo < 6 && (6..10).contains(&hi));
}
}
#[test]
fn hsbm_full_inter_macro_complete_minus_clusters() {
let rho = vec![0.6, 0.4, 0.0];
let c = vec![
vec![0.0, 1.0, 0.0],
vec![1.0, 0.0, 0.0],
vec![0.0, 0.0, 0.0],
];
let g = hsbm_game(10, 5, &rho, &c, 1.0, 0).unwrap();
assert_eq!(g.vcount(), 10);
assert_eq!(g.ecount(), 37);
}
#[test]
fn hsbm_p_zero_isolates_macros() {
let rho = vec![0.5, 0.5];
let c = vec![vec![0.5, 0.1], vec![0.1, 0.5]];
let g = hsbm_game(20, 10, &rho, &c, 0.0, 0xDEAD).unwrap();
for e in 0..g.ecount() as u32 {
let (u, v) = g.edge(e).unwrap();
let bu = u / 10;
let bv = v / 10;
assert_eq!(bu, bv, "edge ({u}, {v}) crosses macros with p=0");
}
}
#[test]
fn hsbm_p_one_emits_all_inter_macro_edges() {
let rho = vec![1.0];
let c = vec![vec![0.0]];
let g = hsbm_game(20, 5, &rho, &c, 1.0, 0).unwrap();
assert_eq!(g.ecount(), 150);
}
#[test]
fn hsbm_no_self_loops_and_no_multi() {
let rho = vec![0.5, 0.5];
let c = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
let g = hsbm_game(20, 10, &rho, &c, 0.5, 0).unwrap();
let mut seen: std::collections::HashSet<(u32, u32)> = std::collections::HashSet::new();
for e in 0..g.ecount() as u32 {
let (u, v) = g.edge(e).unwrap();
assert_ne!(u, v, "self-loop in HSBM");
let key = if u <= v { (u, v) } else { (v, u) };
assert!(seen.insert(key), "duplicate edge {u}-{v}");
}
}
#[test]
fn hsbm_determinism_same_seed_same_graph() {
let rho = vec![0.5, 0.5];
let c = vec![vec![0.3, 0.05], vec![0.05, 0.3]];
let g1 = hsbm_game(40, 10, &rho, &c, 0.05, 0x00C0_FFEE).unwrap();
let g2 = hsbm_game(40, 10, &rho, &c, 0.05, 0x00C0_FFEE).unwrap();
let edges1: Vec<_> = (0..g1.ecount() as u32)
.map(|e| g1.edge(e).unwrap())
.collect();
let edges2: Vec<_> = (0..g2.ecount() as u32)
.map(|e| g2.edge(e).unwrap())
.collect();
assert_eq!(edges1, edges2);
}
#[test]
fn hsbm_list_rejects_length_mismatch() {
let res = hsbm_list_game(10, &[10, 0], &[vec![1.0]], &[vec![vec![0.5]]], 0.0, 0);
assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
}
#[test]
fn hsbm_list_rejects_sum_m_not_n() {
let res = hsbm_list_game(
10,
&[5, 4],
&[vec![1.0], vec![1.0]],
&[vec![vec![0.5]], vec![vec![0.5]]],
0.0,
0,
);
assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
}
#[test]
fn hsbm_list_rejects_empty_macros() {
let res = hsbm_list_game(10, &[], &[], &[], 0.0, 0);
assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
}
#[test]
fn hsbm_list_rejects_zero_size_macro() {
let res = hsbm_list_game(
5,
&[5, 0],
&[vec![1.0], vec![1.0]],
&[vec![vec![0.5]], vec![vec![0.5]]],
0.0,
0,
);
assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
}
#[test]
fn hsbm_list_trivial_one_vertex() {
let g = hsbm_list_game(1, &[1], &[vec![1.0]], &[vec![vec![1.0]]], 0.0, 0).unwrap();
assert_eq!(g.vcount(), 1);
assert_eq!(g.ecount(), 0);
}
#[test]
fn hsbm_list_complete_bipartite_within_one_macro() {
let m_list = vec![10u32];
let rho_list = vec![vec![0.6, 0.4, 0.0]];
let c_list = vec![vec![
vec![0.0, 1.0, 0.0],
vec![1.0, 0.0, 0.0],
vec![0.0, 0.0, 0.0],
]];
let g = hsbm_list_game(10, &m_list, &rho_list, &c_list, 0.0, 0).unwrap();
assert_eq!(g.ecount(), 24);
}
#[test]
fn hsbm_list_two_macros_p_one_full_inter() {
let m_list = vec![5u32, 5];
let rho_list = vec![vec![0.6, 0.4, 0.0], vec![0.6, 0.4, 0.0]];
let c_list = vec![
vec![
vec![0.0, 1.0, 0.0],
vec![1.0, 0.0, 0.0],
vec![0.0, 0.0, 0.0],
],
vec![
vec![0.0, 1.0, 0.0],
vec![1.0, 0.0, 0.0],
vec![0.0, 0.0, 0.0],
],
];
let g = hsbm_list_game(10, &m_list, &rho_list, &c_list, 1.0, 0).unwrap();
assert_eq!(g.ecount(), 37);
}
#[test]
fn hsbm_list_heterogeneous_macros_no_panic() {
let m_list = vec![3u32, 10, 5, 3];
let rho_list = vec![
vec![1.0 / 3.0, 2.0 / 3.0],
vec![3.0 / 10.0, 3.0 / 10.0, 4.0 / 10.0],
vec![1.0],
vec![2.0 / 3.0, 1.0 / 3.0],
];
let c_list = vec![
vec![vec![0.0, 0.0], vec![0.0, 0.0]],
vec![
vec![0.0, 0.0, 0.0],
vec![0.0, 0.0, 0.0],
vec![0.0, 0.0, 0.0],
],
vec![vec![0.0]],
vec![vec![0.0, 0.0], vec![0.0, 0.0]],
];
let g = hsbm_list_game(21, &m_list, &rho_list, &c_list, 1.0, 0).unwrap();
assert_eq!(g.vcount(), 21);
let mut expected = 0u64;
for i in 0..m_list.len() {
for j in (i + 1)..m_list.len() {
expected += u64::from(m_list[i]) * u64::from(m_list[j]);
}
}
assert_eq!(u64::try_from(g.ecount()).unwrap(), expected);
}
#[test]
fn hsbm_list_uniform_matches_hsbm_game() {
let rho = vec![0.5, 0.5];
let c = vec![vec![0.3, 0.05], vec![0.05, 0.3]];
let g1 = hsbm_game(40, 10, &rho, &c, 0.05, 0xABCD).unwrap();
let m_list = vec![10u32; 4];
let rho_list = vec![rho.clone(); 4];
let c_list = vec![c.clone(); 4];
let g2 = hsbm_list_game(40, &m_list, &rho_list, &c_list, 0.05, 0xABCD).unwrap();
let e1: Vec<_> = (0..g1.ecount() as u32)
.map(|e| g1.edge(e).unwrap())
.collect();
let e2: Vec<_> = (0..g2.ecount() as u32)
.map(|e| g2.edge(e).unwrap())
.collect();
assert_eq!(e1, e2);
}
}
#[cfg(all(test, feature = "proptest-harness"))]
mod proptest_invariants {
use super::*;
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(48))]
#[test]
fn hsbm_vcount_matches_n(
no_macros in 1u32..5,
cluster_count in 1usize..4,
seed: u64,
) {
let m = (cluster_count as u32) * 3;
let n = no_macros * m;
let rho = vec![1.0 / cluster_count as f64; cluster_count];
let p_in = 0.2_f64;
let p_off = 0.05_f64;
let mut c = vec![vec![p_off; cluster_count]; cluster_count];
for (i, row) in c.iter_mut().enumerate() {
row[i] = p_in;
}
let g = hsbm_game(n, m, &rho, &c, 0.02, seed).unwrap();
prop_assert_eq!(g.vcount(), n);
}
#[test]
fn hsbm_is_simple(
no_macros in 1u32..4,
seed: u64,
) {
let m = 6u32;
let n = no_macros * m;
let rho = vec![0.5_f64, 0.5];
let c = vec![vec![0.3, 0.05], vec![0.05, 0.3]];
let g = hsbm_game(n, m, &rho, &c, 0.05, seed).unwrap();
let mut seen: std::collections::HashSet<(u32, u32)> = std::collections::HashSet::new();
for e in 0..g.ecount() as u32 {
let (u, v) = g.edge(e).unwrap();
prop_assert_ne!(u, v);
let key = if u <= v { (u, v) } else { (v, u) };
prop_assert!(seen.insert(key));
}
}
#[test]
fn hsbm_p_zero_keeps_edges_inside_macros(
no_macros in 2u32..5,
seed: u64,
) {
let m = 6u32;
let n = no_macros * m;
let rho = vec![0.5_f64, 0.5];
let c = vec![vec![0.4, 0.1], vec![0.1, 0.4]];
let g = hsbm_game(n, m, &rho, &c, 0.0, seed).unwrap();
for e in 0..g.ecount() as u32 {
let (u, v) = g.edge(e).unwrap();
prop_assert_eq!(u / m, v / m);
}
}
#[test]
fn hsbm_list_vcount_matches_sum_m(
sizes in prop::collection::vec(1u32..7, 1usize..4),
seed: u64,
) {
let n: u32 = sizes.iter().sum();
let rho_list: Vec<Vec<f64>> = sizes.iter().map(|_| vec![1.0]).collect();
let c_list: Vec<Vec<Vec<f64>>> = sizes.iter().map(|_| vec![vec![0.2_f64]]).collect();
let g = hsbm_list_game(n, &sizes, &rho_list, &c_list, 0.05, seed).unwrap();
prop_assert_eq!(g.vcount(), n);
}
}
}