Skip to main content

rust_igraph/algorithms/games/
hsbm.rs

1//! Hierarchical Stochastic Block Model (ALGO-GN-011).
2//!
3//! Counterparts of `igraph_hsbm_game()` and `igraph_hsbm_list_game()`
4//! from `references/igraph/src/games/sbm.c:284-648`. Both arrange `n`
5//! vertices into `K` *macro-blocks*. Each macro-block runs its own
6//! internal Stochastic Block Model over `k_b` *micro-blocks*; in
7//! addition every pair of macro-blocks is connected by a Bernoulli(p)
8//! sampler on the rectangular product of their vertex sets.
9//!
10//! * [`hsbm_game`] — the **uniform** variant: every macro-block has the
11//!   same size `m`, the same micro-block proportions `rho`, and the
12//!   same internal preference matrix `c`. Requires `n % m == 0` so the
13//!   number of macro-blocks is exactly `n / m`.
14//! * [`hsbm_list_game`] — the **per-macro** variant: each macro-block
15//!   `b` carries its own size `m_list[b]`, proportions `rho_list[b]`,
16//!   and preference matrix `c_list[b]`. The sizes must sum to `n`.
17//!
18//! Both functions always produce an **undirected, simple** graph
19//! (no self-loops, no multi-edges) — matching the upstream C
20//! signatures, which fix `directed=0` and do not expose a `loops`
21//! or `multiple` flag.
22//!
23//! ## Algorithm
24//!
25//! Each macro-block is sampled independently via the same
26//! Batagelj–Brandes geometric-skip path used by [`super::sbm`], reused
27//! through [`crate::algorithms::games::sbm`] internals. The inter-
28//! macro layer adds, for every pair of macros, a single rectangular
29//! geometric-skip pass at rate `p`. Two fast paths:
30//!
31//! * `p == 1` emits the full bipartite edge set without consulting the
32//!   RNG.
33//! * `p == 0` is skipped entirely.
34//!
35//! Total cost is `O(n + m + sum k_b² + K²)` where `m` is the realised
36//! edge count and `K` is the number of macro-blocks.
37//!
38//! ## Determinism
39//!
40//! Given the same `(args, seed)` the output is bit-exact reproducible
41//! via the shared `SplitMix64` PRNG.
42//!
43//! ## References
44//!
45//! * V. Batagelj and U. Brandes, *"Efficient generation of large
46//!   random networks"*, Phys. Rev. E **71**, 036113 (2005).
47//! * Hierarchical-SBM literature, e.g. Lyzinski et al., *"Community
48//!   detection and classification in hierarchical stochastic
49//!   blockmodels"*, IEEE Trans. Net. Sci. Eng. **4** (2017), 13-26.
50
51#![allow(
52    unknown_lints,
53    clippy::cast_possible_truncation,
54    clippy::cast_precision_loss,
55    clippy::cast_sign_loss,
56    clippy::float_cmp,
57    clippy::too_many_arguments,
58    clippy::similar_names,
59    clippy::manual_midpoint,
60    clippy::many_single_char_names
61)]
62
63use crate::algorithms::games::sbm::{PairShape, block_offsets, sample_pair_with_max};
64use crate::core::rng::SplitMix64;
65use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
66
67/// Tolerance for "sum equals one" and "rho·m is integer" checks. The
68/// upstream C uses `sqrt(DBL_EPSILON)` which is ~1.49e-8.
69const SUM_TOL: f64 = 1.490_116_119_384_765_6e-8;
70
71// ----- validation -----------------------------------------------------
72
73/// Verify a single Bernoulli rate matrix is square `k×k`, symmetric,
74/// and lies in `[0, 1]`.
75fn validate_pref(c: &[Vec<f64>], k: usize, label: &str) -> IgraphResult<()> {
76    if c.len() != k {
77        return Err(IgraphError::InvalidArgument(format!(
78            "{label}: pref matrix has {} rows but rho has length {k}",
79            c.len()
80        )));
81    }
82    for (i, row) in c.iter().enumerate() {
83        if row.len() != k {
84            return Err(IgraphError::InvalidArgument(format!(
85                "{label}: pref matrix row {i} has length {} (expected {k})",
86                row.len()
87            )));
88        }
89        for (j, &val) in row.iter().enumerate() {
90            if !val.is_finite() {
91                return Err(IgraphError::InvalidArgument(format!(
92                    "{label}: pref[{i}][{j}] = {val} is not finite"
93                )));
94            }
95            if !(0.0..=1.0).contains(&val) {
96                return Err(IgraphError::InvalidArgument(format!(
97                    "{label}: pref[{i}][{j}] = {val} must lie in [0, 1]"
98                )));
99            }
100        }
101    }
102    for (i, row_i) in c.iter().enumerate() {
103        for (j, row_j) in c.iter().enumerate().skip(i + 1) {
104            let pij = row_i[j];
105            let pji = row_j[i];
106            if pij != pji {
107                return Err(IgraphError::InvalidArgument(format!(
108                    "{label}: pref matrix is not symmetric: pref[{i}][{j}] = {pij}, \
109                     pref[{j}][{i}] = {pji}"
110                )));
111            }
112        }
113    }
114    Ok(())
115}
116
117/// Verify `rho ⊂ [0, 1]` and `sum(rho) == 1` within `SUM_TOL`.
118fn validate_rho(rho: &[f64], label: &str) -> IgraphResult<()> {
119    if rho.is_empty() {
120        return Err(IgraphError::InvalidArgument(format!(
121            "{label}: rho must contain at least one entry"
122        )));
123    }
124    let mut acc = 0.0;
125    for (i, &val) in rho.iter().enumerate() {
126        if !val.is_finite() {
127            return Err(IgraphError::InvalidArgument(format!(
128                "{label}: rho[{i}] = {val} is not finite"
129            )));
130        }
131        if !(0.0..=1.0).contains(&val) {
132            return Err(IgraphError::InvalidArgument(format!(
133                "{label}: rho[{i}] = {val} must lie in [0, 1]"
134            )));
135        }
136        acc += val;
137    }
138    if (acc - 1.0).abs() > SUM_TOL {
139        return Err(IgraphError::InvalidArgument(format!(
140            "{label}: sum(rho) = {acc} must equal 1 (tolerance {SUM_TOL})"
141        )));
142    }
143    Ok(())
144}
145
146/// Verify that every `rho[i] * m` is (close to) an integer, and return
147/// the resulting csizes vector summing to `m`. Distribute any
148/// rounding residue onto the last positive-rho entry so the sum is
149/// exactly `m`, mirroring how the upstream tolerance check would
150/// accept the inputs but the final sum needs to be exact.
151fn csizes_for(rho: &[f64], m: u32, label: &str) -> IgraphResult<Vec<u32>> {
152    let m_f = f64::from(m);
153    let mut sizes = Vec::with_capacity(rho.len());
154    for (i, &r) in rho.iter().enumerate() {
155        let s = r * m_f;
156        let rounded = s.round();
157        if (rounded - s).abs() > SUM_TOL {
158            return Err(IgraphError::InvalidArgument(format!(
159                "{label}: rho[{i}] * m = {s} is not an integer (tolerance {SUM_TOL})"
160            )));
161        }
162        if rounded < 0.0 || rounded > f64::from(u32::MAX) {
163            return Err(IgraphError::InvalidArgument(format!(
164                "{label}: rho[{i}] * m = {rounded} is out of u32 range"
165            )));
166        }
167        sizes.push(rounded as u32);
168    }
169    let sum: u64 = sizes.iter().copied().map(u64::from).sum();
170    if sum != u64::from(m) {
171        return Err(IgraphError::InvalidArgument(format!(
172            "{label}: sum of round(rho[i] * m) = {sum} but m = {m}"
173        )));
174    }
175    Ok(sizes)
176}
177
178// ----- intra-macro sampling -------------------------------------------
179
180/// Run a single macro-block's intra-SBM, appending edges to `edges`
181/// with vertex ids offset by `macro_off`.
182fn sample_intra_macro(
183    rng: &mut SplitMix64,
184    edges: &mut Vec<(VertexId, VertexId)>,
185    macro_off: u32,
186    csizes: &[u32],
187    c: &[Vec<f64>],
188) {
189    let k = csizes.len();
190    // Cumulative offsets within this macro-block.
191    let mut intra_off = vec![0u32; k + 1];
192    let mut acc = 0u32;
193    for (i, &s) in csizes.iter().enumerate() {
194        acc += s;
195        intra_off[i + 1] = acc;
196    }
197    for from in 0..k {
198        let fromsize = csizes[from];
199        if fromsize == 0 {
200            continue;
201        }
202        let fromoff = macro_off + intra_off[from];
203        for to in from..k {
204            let tosize = csizes[to];
205            if tosize == 0 {
206                continue;
207            }
208            let tooff = macro_off + intra_off[to];
209            let prob = c[from][to];
210            if prob <= 0.0 {
211                continue;
212            }
213            let (shape, maxedges) = if from == to {
214                let fs = u64::from(fromsize);
215                (PairShape::TriExclDiag, fs * fs.saturating_sub(1) / 2)
216            } else {
217                (PairShape::Rect, u64::from(fromsize) * u64::from(tosize))
218            };
219            sample_pair_with_max(
220                rng, edges, fromsize, fromoff, tooff, shape, false, prob, maxedges,
221            );
222        }
223    }
224}
225
226// ----- inter-macro sampling -------------------------------------------
227
228/// Emit every edge between two disjoint macro-block vertex ranges
229/// (full bipartite). Used only when `p == 1`.
230fn full_bipartite(
231    edges: &mut Vec<(VertexId, VertexId)>,
232    fromoff: u32,
233    fromsize: u32,
234    tooff: u32,
235    tosize: u32,
236) {
237    for u in 0..fromsize {
238        for v in 0..tosize {
239            edges.push((fromoff + u, tooff + v));
240        }
241    }
242}
243
244/// Rectangular geometric-skip pass between two disjoint macro-block
245/// vertex ranges at rate `p`. Caller must ensure `0 < p < 1`.
246fn rect_between_macros(
247    rng: &mut SplitMix64,
248    edges: &mut Vec<(VertexId, VertexId)>,
249    fromoff: u32,
250    fromsize: u32,
251    tooff: u32,
252    tosize: u32,
253    p: f64,
254) {
255    if fromsize == 0 || tosize == 0 || p <= 0.0 {
256        return;
257    }
258    let maxedges = u64::from(fromsize) * u64::from(tosize);
259    sample_pair_with_max(
260        rng,
261        edges,
262        fromsize,
263        fromoff,
264        tooff,
265        PairShape::Rect,
266        false,
267        p,
268        maxedges,
269    );
270}
271
272/// Add inter-macro edges for every pair `(b1, b2)` with `b1 < b2`.
273fn add_inter_macro(
274    rng: &mut SplitMix64,
275    edges: &mut Vec<(VertexId, VertexId)>,
276    macro_offsets: &[u32],
277    macro_sizes: &[u32],
278    p: f64,
279) {
280    if p <= 0.0 {
281        return;
282    }
283    let k = macro_sizes.len();
284    if p >= 1.0 {
285        for b1 in 0..k {
286            let fromoff = macro_offsets[b1];
287            let fromsize = macro_sizes[b1];
288            if fromsize == 0 {
289                continue;
290            }
291            for b2 in (b1 + 1)..k {
292                let tosize = macro_sizes[b2];
293                if tosize == 0 {
294                    continue;
295                }
296                full_bipartite(edges, fromoff, fromsize, macro_offsets[b2], tosize);
297            }
298        }
299        return;
300    }
301    for b1 in 0..k {
302        let fromoff = macro_offsets[b1];
303        let fromsize = macro_sizes[b1];
304        if fromsize == 0 {
305            continue;
306        }
307        for b2 in (b1 + 1)..k {
308            let tosize = macro_sizes[b2];
309            if tosize == 0 {
310                continue;
311            }
312            rect_between_macros(rng, edges, fromoff, fromsize, macro_offsets[b2], tosize, p);
313        }
314    }
315}
316
317// ----- public API -----------------------------------------------------
318
319/// Generate a graph from the **uniform Hierarchical Stochastic Block
320/// Model**: every macro-block is the same size `m` and shares the same
321/// internal preference matrix.
322///
323/// * `n` — total vertex count. Must satisfy `n >= 1` and `n % m == 0`.
324/// * `m` — micro-vertex count per macro-block. Must satisfy `m >= 1`.
325///   The number of macro-blocks is `n / m`.
326/// * `rho` — micro-block proportions inside each macro-block. Length
327///   `k`, entries in `[0, 1]`, sum equal to `1` within
328///   `√DBL_EPSILON`. Each `rho[j] * m` must also be (within tolerance)
329///   an integer.
330/// * `c` — `k × k` symmetric Bernoulli pref matrix on the micro-blocks,
331///   entries in `[0, 1]`.
332/// * `p` — Bernoulli rate for every edge crossing two distinct
333///   macro-blocks. Must lie in `[0, 1]`.
334/// * `seed` — initialises an internal `SplitMix64` PRNG.
335///
336/// The returned graph is always undirected, simple (no loops, no
337/// multi-edges). Vertex ordering follows macro-block then micro-block:
338/// macro `b` occupies the range `[b*m, (b+1)*m)`, and inside it
339/// micro-block `j` occupies `[b*m + offset_j, b*m + offset_{j+1})`.
340///
341/// # Errors
342///
343/// Returns [`IgraphError::InvalidArgument`] when any of the constraints
344/// above is violated.
345///
346/// # Examples
347///
348/// ```
349/// use rust_igraph::hsbm_game;
350///
351/// // 2 macro-blocks of size 10, each with 2 micro-blocks of 5,
352/// // strong within-cluster ties and a 1 % between-macro rate.
353/// let rho = vec![0.5, 0.5];
354/// let c = vec![vec![0.8, 0.05], vec![0.05, 0.8]];
355/// let g = hsbm_game(20, 10, &rho, &c, 0.01, 42).unwrap();
356/// assert_eq!(g.vcount(), 20);
357/// assert!(!g.is_directed());
358/// ```
359pub fn hsbm_game(
360    n: u32,
361    m: u32,
362    rho: &[f64],
363    c: &[Vec<f64>],
364    p: f64,
365    seed: u64,
366) -> IgraphResult<Graph> {
367    if n == 0 {
368        return Err(IgraphError::InvalidArgument(
369            "hsbm_game: n must be at least 1".into(),
370        ));
371    }
372    if m == 0 {
373        return Err(IgraphError::InvalidArgument(
374            "hsbm_game: m must be at least 1".into(),
375        ));
376    }
377    if n % m != 0 {
378        return Err(IgraphError::InvalidArgument(format!(
379            "hsbm_game: n ({n}) must be a multiple of m ({m})"
380        )));
381    }
382    if !p.is_finite() || !(0.0..=1.0).contains(&p) {
383        return Err(IgraphError::InvalidArgument(format!(
384            "hsbm_game: p = {p} must lie in [0, 1]"
385        )));
386    }
387    let k = rho.len();
388    validate_rho(rho, "hsbm_game")?;
389    validate_pref(c, k, "hsbm_game")?;
390    let csizes = csizes_for(rho, m, "hsbm_game")?;
391    let no_macros = n / m;
392
393    let mut rng = SplitMix64::new(seed);
394    let mut edges: Vec<(VertexId, VertexId)> = Vec::new();
395
396    // Phase 1: intra-macro.
397    for b in 0..no_macros {
398        let macro_off = b * m;
399        sample_intra_macro(&mut rng, &mut edges, macro_off, &csizes, c);
400    }
401
402    // Phase 2: inter-macro.
403    let mut macro_offsets = Vec::with_capacity(no_macros as usize + 1);
404    let mut macro_sizes = Vec::with_capacity(no_macros as usize);
405    for b in 0..no_macros {
406        macro_offsets.push(b * m);
407        macro_sizes.push(m);
408    }
409    macro_offsets.push(no_macros * m);
410    add_inter_macro(&mut rng, &mut edges, &macro_offsets, &macro_sizes, p);
411
412    let mut g = Graph::new(n, false)?;
413    g.add_edges(edges)?;
414    Ok(g)
415}
416
417/// Generate a graph from the **per-macro Hierarchical Stochastic Block
418/// Model**: every macro-block carries its own size, its own
419/// micro-block proportions, and its own internal preference matrix.
420///
421/// * `n` — total vertex count. Must equal `m_list.iter().sum()`.
422/// * `m_list` — length-`K` vector of macro-block sizes. Each entry
423///   must be at least 1.
424/// * `rho_list` — length-`K` list; entry `b` is the micro-block
425///   proportion vector for macro-block `b`. Each `rho_list[b]` must
426///   sum to 1 within `√DBL_EPSILON`, and every `rho_list[b][j] *
427///   m_list[b]` must be (within tolerance) an integer.
428/// * `c_list` — length-`K` list; entry `b` is the
429///   `k_b × k_b` symmetric Bernoulli pref matrix for macro-block `b`.
430///   Entries in `[0, 1]`.
431/// * `p` — Bernoulli rate for every edge crossing two distinct
432///   macro-blocks. Must lie in `[0, 1]`.
433/// * `seed` — initialises an internal `SplitMix64` PRNG.
434///
435/// The returned graph is always undirected, simple. Vertex ordering
436/// follows the macro-block order: macro `b` occupies
437/// `[sum_{i<b} m_list[i], sum_{i<=b} m_list[i])`.
438///
439/// # Errors
440///
441/// Returns [`IgraphError::InvalidArgument`] when any of the constraints
442/// above is violated, including length mismatches between `m_list`,
443/// `rho_list`, and `c_list`.
444///
445/// # Examples
446///
447/// ```
448/// use rust_igraph::hsbm_list_game;
449///
450/// // 3 macro-blocks of size 4, 6, 10. Each runs its own inner SBM.
451/// let m_list = vec![4u32, 6, 10];
452/// let rho_list = vec![
453///     vec![1.0],          // single cluster
454///     vec![0.5, 0.5],     // two equal clusters of 3
455///     vec![0.5, 0.5],     // two equal clusters of 5
456/// ];
457/// let c_list = vec![
458///     vec![vec![0.5]],
459///     vec![vec![0.4, 0.1], vec![0.1, 0.4]],
460///     vec![vec![0.3, 0.05], vec![0.05, 0.3]],
461/// ];
462/// let g = hsbm_list_game(20, &m_list, &rho_list, &c_list, 0.02, 7).unwrap();
463/// assert_eq!(g.vcount(), 20);
464/// ```
465pub fn hsbm_list_game(
466    n: u32,
467    m_list: &[u32],
468    rho_list: &[Vec<f64>],
469    c_list: &[Vec<Vec<f64>>],
470    p: f64,
471    seed: u64,
472) -> IgraphResult<Graph> {
473    if n == 0 {
474        return Err(IgraphError::InvalidArgument(
475            "hsbm_list_game: n must be at least 1".into(),
476        ));
477    }
478    if !p.is_finite() || !(0.0..=1.0).contains(&p) {
479        return Err(IgraphError::InvalidArgument(format!(
480            "hsbm_list_game: p = {p} must lie in [0, 1]"
481        )));
482    }
483    let no_macros = rho_list.len();
484    if no_macros == 0 {
485        return Err(IgraphError::InvalidArgument(
486            "hsbm_list_game: rho_list must contain at least one entry".into(),
487        ));
488    }
489    if m_list.len() != no_macros || c_list.len() != no_macros {
490        return Err(IgraphError::InvalidArgument(format!(
491            "hsbm_list_game: rho_list ({no_macros}), m_list ({}) and c_list ({}) \
492             must have the same length",
493            m_list.len(),
494            c_list.len()
495        )));
496    }
497    if m_list.contains(&0) {
498        return Err(IgraphError::InvalidArgument(
499            "hsbm_list_game: every m_list[b] must be at least 1".into(),
500        ));
501    }
502    let sum_m: u64 = m_list.iter().copied().map(u64::from).sum();
503    if sum_m != u64::from(n) {
504        return Err(IgraphError::InvalidArgument(format!(
505            "hsbm_list_game: sum(m_list) = {sum_m} but n = {n}"
506        )));
507    }
508    let (macro_offsets, _total) = block_offsets(m_list)?;
509
510    let mut all_csizes: Vec<Vec<u32>> = Vec::with_capacity(no_macros);
511    for b in 0..no_macros {
512        let k = rho_list[b].len();
513        let label = format!("hsbm_list_game (macro {b})");
514        validate_rho(&rho_list[b], &label)?;
515        validate_pref(&c_list[b], k, &label)?;
516        let csizes = csizes_for(&rho_list[b], m_list[b], &label)?;
517        all_csizes.push(csizes);
518    }
519
520    let mut rng = SplitMix64::new(seed);
521    let mut edges: Vec<(VertexId, VertexId)> = Vec::new();
522
523    // Phase 1: intra-macro.
524    for b in 0..no_macros {
525        sample_intra_macro(
526            &mut rng,
527            &mut edges,
528            macro_offsets[b],
529            &all_csizes[b],
530            &c_list[b],
531        );
532    }
533
534    // Phase 2: inter-macro.
535    add_inter_macro(&mut rng, &mut edges, &macro_offsets, m_list, p);
536
537    let mut g = Graph::new(n, false)?;
538    g.add_edges(edges)?;
539    Ok(g)
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    // -- validation: hsbm_game ------------------------------------
547
548    #[test]
549    fn hsbm_rejects_n_not_multiple_of_m() {
550        let rho = vec![1.0];
551        let c = vec![vec![0.5]];
552        let res = hsbm_game(10, 3, &rho, &c, 0.0, 0);
553        assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
554    }
555
556    #[test]
557    fn hsbm_rejects_zero_n() {
558        let rho = vec![1.0];
559        let c = vec![vec![0.5]];
560        let res = hsbm_game(0, 1, &rho, &c, 0.0, 0);
561        assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
562    }
563
564    #[test]
565    fn hsbm_rejects_zero_m() {
566        let rho = vec![1.0];
567        let c = vec![vec![0.5]];
568        let res = hsbm_game(10, 0, &rho, &c, 0.0, 0);
569        assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
570    }
571
572    #[test]
573    fn hsbm_rejects_p_out_of_range() {
574        let rho = vec![1.0];
575        let c = vec![vec![0.5]];
576        let res = hsbm_game(10, 5, &rho, &c, 1.5, 0);
577        assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
578    }
579
580    #[test]
581    fn hsbm_rejects_rho_not_summing_to_one() {
582        let rho = vec![0.3, 0.3];
583        let c = vec![vec![0.5, 0.5], vec![0.5, 0.5]];
584        let res = hsbm_game(10, 5, &rho, &c, 0.0, 0);
585        assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
586    }
587
588    #[test]
589    fn hsbm_rejects_rho_times_m_not_integer() {
590        // rho[0]*m = 0.5*7 = 3.5, not integer.
591        let rho = vec![0.5, 0.5];
592        let c = vec![vec![0.5, 0.5], vec![0.5, 0.5]];
593        let res = hsbm_game(14, 7, &rho, &c, 0.0, 0);
594        assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
595    }
596
597    #[test]
598    fn hsbm_rejects_asymmetric_c() {
599        let rho = vec![0.5, 0.5];
600        let c = vec![vec![0.5, 0.2], vec![0.3, 0.5]];
601        let res = hsbm_game(10, 5, &rho, &c, 0.0, 0);
602        assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
603    }
604
605    #[test]
606    fn hsbm_rejects_c_out_of_range() {
607        let rho = vec![1.0];
608        let c = vec![vec![1.5]];
609        let res = hsbm_game(10, 5, &rho, &c, 0.0, 0);
610        assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
611    }
612
613    #[test]
614    fn hsbm_rejects_pref_wrong_shape() {
615        let rho = vec![0.5, 0.5];
616        let c = vec![vec![0.5]];
617        let res = hsbm_game(10, 5, &rho, &c, 0.0, 0);
618        assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
619    }
620
621    // -- structural correctness: hsbm_game ------------------------
622
623    #[test]
624    fn hsbm_trivial_one_vertex() {
625        // Reproduces igraph_hsbm_game.out fixture #1: 1 vertex, 0 edges.
626        let rho = vec![1.0];
627        let c = vec![vec![1.0]];
628        let g = hsbm_game(1, 1, &rho, &c, 0.0, 0).unwrap();
629        assert_eq!(g.vcount(), 1);
630        assert_eq!(g.ecount(), 0);
631        assert!(!g.is_directed());
632    }
633
634    #[test]
635    fn hsbm_complete_bipartite_within_macro() {
636        // Reproduces fixture #2: one macro of 10, three clusters
637        // sized 6, 4, 0; off-diagonal pref = 1 → complete bipartite
638        // K_{6,4}; p=0 → no inter-macro work.
639        let rho = vec![0.6, 0.4, 0.0];
640        let c = vec![
641            vec![0.0, 1.0, 0.0],
642            vec![1.0, 0.0, 0.0],
643            vec![0.0, 0.0, 0.0],
644        ];
645        let g = hsbm_game(10, 10, &rho, &c, 0.0, 0).unwrap();
646        assert_eq!(g.vcount(), 10);
647        // K_{6,4} has 24 edges.
648        assert_eq!(g.ecount(), 24);
649        // Every edge crosses the {0..6} vs {6..10} partition.
650        for e in 0..g.ecount() as u32 {
651            let (u, v) = g.edge(e).unwrap();
652            let (lo, hi) = if u < v { (u, v) } else { (v, u) };
653            assert!(lo < 6 && (6..10).contains(&hi));
654        }
655    }
656
657    #[test]
658    fn hsbm_full_inter_macro_complete_minus_clusters() {
659        // Reproduces fixture #3: two macros of 5, intra pref [0 1; 1 0]
660        // (3,2 clusters → 6 intra-macro edges per macro), p=1
661        // → complete bipartite between macros = 25 edges.
662        // Total expected = 2*6 + 25 = 37 edges.
663        let rho = vec![0.6, 0.4, 0.0];
664        let c = vec![
665            vec![0.0, 1.0, 0.0],
666            vec![1.0, 0.0, 0.0],
667            vec![0.0, 0.0, 0.0],
668        ];
669        let g = hsbm_game(10, 5, &rho, &c, 1.0, 0).unwrap();
670        assert_eq!(g.vcount(), 10);
671        assert_eq!(g.ecount(), 37);
672    }
673
674    #[test]
675    fn hsbm_p_zero_isolates_macros() {
676        // p = 0 means no inter-macro edges.
677        let rho = vec![0.5, 0.5];
678        let c = vec![vec![0.5, 0.1], vec![0.1, 0.5]];
679        let g = hsbm_game(20, 10, &rho, &c, 0.0, 0xDEAD).unwrap();
680        for e in 0..g.ecount() as u32 {
681            let (u, v) = g.edge(e).unwrap();
682            let bu = u / 10;
683            let bv = v / 10;
684            assert_eq!(bu, bv, "edge ({u}, {v}) crosses macros with p=0");
685        }
686    }
687
688    #[test]
689    fn hsbm_p_one_emits_all_inter_macro_edges() {
690        // p=1 with c=0 inside macros: expect every cross-macro pair.
691        let rho = vec![1.0];
692        let c = vec![vec![0.0]];
693        let g = hsbm_game(20, 5, &rho, &c, 1.0, 0).unwrap();
694        // 4 macros of 5; cross-macro edges = C(4,2)*5*5 = 6*25 = 150.
695        assert_eq!(g.ecount(), 150);
696    }
697
698    #[test]
699    fn hsbm_no_self_loops_and_no_multi() {
700        let rho = vec![0.5, 0.5];
701        let c = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
702        let g = hsbm_game(20, 10, &rho, &c, 0.5, 0).unwrap();
703        let mut seen: std::collections::HashSet<(u32, u32)> = std::collections::HashSet::new();
704        for e in 0..g.ecount() as u32 {
705            let (u, v) = g.edge(e).unwrap();
706            assert_ne!(u, v, "self-loop in HSBM");
707            let key = if u <= v { (u, v) } else { (v, u) };
708            assert!(seen.insert(key), "duplicate edge {u}-{v}");
709        }
710    }
711
712    #[test]
713    fn hsbm_determinism_same_seed_same_graph() {
714        let rho = vec![0.5, 0.5];
715        let c = vec![vec![0.3, 0.05], vec![0.05, 0.3]];
716        let g1 = hsbm_game(40, 10, &rho, &c, 0.05, 0x00C0_FFEE).unwrap();
717        let g2 = hsbm_game(40, 10, &rho, &c, 0.05, 0x00C0_FFEE).unwrap();
718        let edges1: Vec<_> = (0..g1.ecount() as u32)
719            .map(|e| g1.edge(e).unwrap())
720            .collect();
721        let edges2: Vec<_> = (0..g2.ecount() as u32)
722            .map(|e| g2.edge(e).unwrap())
723            .collect();
724        assert_eq!(edges1, edges2);
725    }
726
727    // -- validation: hsbm_list_game --------------------------------
728
729    #[test]
730    fn hsbm_list_rejects_length_mismatch() {
731        let res = hsbm_list_game(10, &[10, 0], &[vec![1.0]], &[vec![vec![0.5]]], 0.0, 0);
732        assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
733    }
734
735    #[test]
736    fn hsbm_list_rejects_sum_m_not_n() {
737        let res = hsbm_list_game(
738            10,
739            &[5, 4],
740            &[vec![1.0], vec![1.0]],
741            &[vec![vec![0.5]], vec![vec![0.5]]],
742            0.0,
743            0,
744        );
745        assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
746    }
747
748    #[test]
749    fn hsbm_list_rejects_empty_macros() {
750        let res = hsbm_list_game(10, &[], &[], &[], 0.0, 0);
751        assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
752    }
753
754    #[test]
755    fn hsbm_list_rejects_zero_size_macro() {
756        let res = hsbm_list_game(
757            5,
758            &[5, 0],
759            &[vec![1.0], vec![1.0]],
760            &[vec![vec![0.5]], vec![vec![0.5]]],
761            0.0,
762            0,
763        );
764        assert!(matches!(res, Err(IgraphError::InvalidArgument(_))));
765    }
766
767    // -- structural correctness: hsbm_list_game --------------------
768
769    #[test]
770    fn hsbm_list_trivial_one_vertex() {
771        let g = hsbm_list_game(1, &[1], &[vec![1.0]], &[vec![vec![1.0]]], 0.0, 0).unwrap();
772        assert_eq!(g.vcount(), 1);
773        assert_eq!(g.ecount(), 0);
774    }
775
776    #[test]
777    fn hsbm_list_complete_bipartite_within_one_macro() {
778        // Same as the C #2 fixture but expressed in list form.
779        let m_list = vec![10u32];
780        let rho_list = vec![vec![0.6, 0.4, 0.0]];
781        let c_list = vec![vec![
782            vec![0.0, 1.0, 0.0],
783            vec![1.0, 0.0, 0.0],
784            vec![0.0, 0.0, 0.0],
785        ]];
786        let g = hsbm_list_game(10, &m_list, &rho_list, &c_list, 0.0, 0).unwrap();
787        assert_eq!(g.ecount(), 24);
788    }
789
790    #[test]
791    fn hsbm_list_two_macros_p_one_full_inter() {
792        // Two macros of size 5; intra pref off-diagonal = 1 → K_{3,2}
793        // per macro (6 edges each); p=1 → 25 inter edges. Total 37.
794        let m_list = vec![5u32, 5];
795        let rho_list = vec![vec![0.6, 0.4, 0.0], vec![0.6, 0.4, 0.0]];
796        let c_list = vec![
797            vec![
798                vec![0.0, 1.0, 0.0],
799                vec![1.0, 0.0, 0.0],
800                vec![0.0, 0.0, 0.0],
801            ],
802            vec![
803                vec![0.0, 1.0, 0.0],
804                vec![1.0, 0.0, 0.0],
805                vec![0.0, 0.0, 0.0],
806            ],
807        ];
808        let g = hsbm_list_game(10, &m_list, &rho_list, &c_list, 1.0, 0).unwrap();
809        assert_eq!(g.ecount(), 37);
810    }
811
812    #[test]
813    fn hsbm_list_heterogeneous_macros_no_panic() {
814        // Macros of different size and different inner k.
815        let m_list = vec![3u32, 10, 5, 3];
816        let rho_list = vec![
817            vec![1.0 / 3.0, 2.0 / 3.0],
818            vec![3.0 / 10.0, 3.0 / 10.0, 4.0 / 10.0],
819            vec![1.0],
820            vec![2.0 / 3.0, 1.0 / 3.0],
821        ];
822        let c_list = vec![
823            vec![vec![0.0, 0.0], vec![0.0, 0.0]],
824            vec![
825                vec![0.0, 0.0, 0.0],
826                vec![0.0, 0.0, 0.0],
827                vec![0.0, 0.0, 0.0],
828            ],
829            vec![vec![0.0]],
830            vec![vec![0.0, 0.0], vec![0.0, 0.0]],
831        ];
832        let g = hsbm_list_game(21, &m_list, &rho_list, &c_list, 1.0, 0).unwrap();
833        assert_eq!(g.vcount(), 21);
834        // p=1, all intra=0 → expected edges = sum over (b1<b2) m_b1 * m_b2.
835        let mut expected = 0u64;
836        for i in 0..m_list.len() {
837            for j in (i + 1)..m_list.len() {
838                expected += u64::from(m_list[i]) * u64::from(m_list[j]);
839            }
840        }
841        assert_eq!(u64::try_from(g.ecount()).unwrap(), expected);
842    }
843
844    #[test]
845    fn hsbm_list_uniform_matches_hsbm_game() {
846        // hsbm_list_game with uniform args must produce the same graph
847        // as hsbm_game given the same seed.
848        let rho = vec![0.5, 0.5];
849        let c = vec![vec![0.3, 0.05], vec![0.05, 0.3]];
850        let g1 = hsbm_game(40, 10, &rho, &c, 0.05, 0xABCD).unwrap();
851
852        let m_list = vec![10u32; 4];
853        let rho_list = vec![rho.clone(); 4];
854        let c_list = vec![c.clone(); 4];
855        let g2 = hsbm_list_game(40, &m_list, &rho_list, &c_list, 0.05, 0xABCD).unwrap();
856
857        let e1: Vec<_> = (0..g1.ecount() as u32)
858            .map(|e| g1.edge(e).unwrap())
859            .collect();
860        let e2: Vec<_> = (0..g2.ecount() as u32)
861            .map(|e| g2.edge(e).unwrap())
862            .collect();
863        assert_eq!(e1, e2);
864    }
865}
866
867#[cfg(all(test, feature = "proptest-harness"))]
868mod proptest_invariants {
869    use super::*;
870    use proptest::prelude::*;
871
872    proptest! {
873        #![proptest_config(ProptestConfig::with_cases(48))]
874
875        /// HSBM uniform: vertex count is `n` regardless of args.
876        #[test]
877        fn hsbm_vcount_matches_n(
878            no_macros in 1u32..5,
879            cluster_count in 1usize..4,
880            seed: u64,
881        ) {
882            let m = (cluster_count as u32) * 3;
883            let n = no_macros * m;
884            let rho = vec![1.0 / cluster_count as f64; cluster_count];
885            let p_in = 0.2_f64;
886            let p_off = 0.05_f64;
887            let mut c = vec![vec![p_off; cluster_count]; cluster_count];
888            for (i, row) in c.iter_mut().enumerate() {
889                row[i] = p_in;
890            }
891            let g = hsbm_game(n, m, &rho, &c, 0.02, seed).unwrap();
892            prop_assert_eq!(g.vcount(), n);
893        }
894
895        /// HSBM uniform: never self-loops, never multi-edges.
896        #[test]
897        fn hsbm_is_simple(
898            no_macros in 1u32..4,
899            seed: u64,
900        ) {
901            let m = 6u32;
902            let n = no_macros * m;
903            let rho = vec![0.5_f64, 0.5];
904            let c = vec![vec![0.3, 0.05], vec![0.05, 0.3]];
905            let g = hsbm_game(n, m, &rho, &c, 0.05, seed).unwrap();
906            let mut seen: std::collections::HashSet<(u32, u32)> = std::collections::HashSet::new();
907            for e in 0..g.ecount() as u32 {
908                let (u, v) = g.edge(e).unwrap();
909                prop_assert_ne!(u, v);
910                let key = if u <= v { (u, v) } else { (v, u) };
911                prop_assert!(seen.insert(key));
912            }
913        }
914
915        /// HSBM uniform: with `p = 0` every edge stays within its
916        /// macro-block (i.e. `u / m == v / m`).
917        #[test]
918        fn hsbm_p_zero_keeps_edges_inside_macros(
919            no_macros in 2u32..5,
920            seed: u64,
921        ) {
922            let m = 6u32;
923            let n = no_macros * m;
924            let rho = vec![0.5_f64, 0.5];
925            let c = vec![vec![0.4, 0.1], vec![0.1, 0.4]];
926            let g = hsbm_game(n, m, &rho, &c, 0.0, seed).unwrap();
927            for e in 0..g.ecount() as u32 {
928                let (u, v) = g.edge(e).unwrap();
929                prop_assert_eq!(u / m, v / m);
930            }
931        }
932
933        /// HSBM list: vertex count equals sum of `m_list`.
934        #[test]
935        fn hsbm_list_vcount_matches_sum_m(
936            sizes in prop::collection::vec(1u32..7, 1usize..4),
937            seed: u64,
938        ) {
939            let n: u32 = sizes.iter().sum();
940            let rho_list: Vec<Vec<f64>> = sizes.iter().map(|_| vec![1.0]).collect();
941            let c_list: Vec<Vec<Vec<f64>>> = sizes.iter().map(|_| vec![vec![0.2_f64]]).collect();
942            let g = hsbm_list_game(n, &sizes, &rho_list, &c_list, 0.05, seed).unwrap();
943            prop_assert_eq!(g.vcount(), n);
944        }
945    }
946}