Skip to main content

rust_igraph/algorithms/games/
iea_game.rs

1//! Independent Edge Allocation random multigraph generator (ALGO-GN-031).
2//!
3//! Counterpart of `igraph_iea_game()` from
4//! `references/igraph/src/games/erdos_renyi.c:480-542`.
5//!
6//! The IEA model generates a random multigraph on `n` vertices with
7//! exactly `m` edges. Each edge is independently assigned to an *ordered*
8//! vertex pair drawn uniformly at random; when `loops` is `false` the
9//! pair `(u, u)` is excluded from the sample space.
10//!
11//! Because every edge is sampled independently, the model:
12//!
13//! * Always produces exactly `m` edges (no rejection sampling against
14//!   simplicity).
15//! * Admits multi-edges by construction — the same vertex pair can be
16//!   drawn many times.
17//! * Optionally admits self-loops (when `loops = true`).
18//!
19//! This sampler is **not** uniform over the space of multigraphs. As
20//! upstream's docstring notes, the probability of a directed multigraph
21//! is proportional to `1 / Π_{i,j} A_ij!` (with the usual double-factorial
22//! correction for the undirected diagonal). Two equivalent
23//! interpretations:
24//!
25//! * It uniformly samples **edge-labelled** graphs in which the `m` edges
26//!   are distinguishable.
27//! * For the special case of simple graphs (i.e. multigraph realisations
28//!   without repeated pairs) the IEA model gives every simple graph the
29//!   same probability — but conditional on simplicity, the sampler is
30//!   equivalent to G(n, m) without replacement.
31//!
32//! Use [`crate::erdos_renyi_gnm`] when uniform sampling over **simple**
33//! graphs is required, or when avoiding multi-edges is a hard constraint.
34//!
35//! Time complexity: `O(|V| + |E|)` — one PRNG draw (two for `directed &&
36//! loops`) per edge plus the final `Graph::add_edges` insertion.
37//!
38//! ## Scope
39//!
40//! Direct port of `igraph_iea_game`. No special arguments; mirrors the
41//! upstream `(n, m, directed, loops)` signature plus our standard `seed`.
42//! The `IGRAPH_EXPERIMENTAL` tag upstream applies to the C API, not to
43//! the algorithm — the model itself is well-established (independent
44//! edge allocation, sometimes called "edge-labelled Erdős–Rényi" or the
45//! "random multigraph with given size").
46//!
47//! ## References
48//!
49//! * Janson, S. *"Random graphs and trees"*. The independent-edge-
50//!   assignment construction is the textbook starting point for many
51//!   multigraph results.
52//! * igraph C documentation for `igraph_iea_game` (experimental API).
53
54#![allow(
55    clippy::cast_possible_truncation,
56    clippy::cast_precision_loss,
57    clippy::cast_sign_loss
58)]
59
60use crate::core::rng::SplitMix64;
61use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
62
63/// Maximum addressable edge count (mirrors `IGRAPH_ECOUNT_MAX` —
64/// upstream's per-graph edge cap). Keeps allocator pressure bounded.
65const IEA_ECOUNT_MAX: u64 = u32::MAX as u64;
66
67fn validate(n: u32, m: u64, loops: bool) -> IgraphResult<()> {
68    if m > IEA_ECOUNT_MAX {
69        return Err(IgraphError::InvalidArgument(format!(
70            "iea_game requested {m} edges, exceeds the {IEA_ECOUNT_MAX} cap"
71        )));
72    }
73    if m == 0 {
74        return Ok(());
75    }
76    if n == 0 {
77        return Err(IgraphError::InvalidArgument(format!(
78            "iea_game cannot place {m} edges on 0 vertices"
79        )));
80    }
81    if !loops && n < 2 {
82        return Err(IgraphError::InvalidArgument(format!(
83            "iea_game without loops requires n >= 2 when m > 0 (got n = {n})"
84        )));
85    }
86    Ok(())
87}
88
89/// Generate a random multigraph through independent edge allocation.
90///
91/// * `n` — number of vertices.
92/// * `m` — number of edges to draw. The result graph has exactly `m`
93///   edges (including any multi-edges or self-loops that occur).
94/// * `directed` — generate a directed graph. When `false`, the sampled
95///   ordered pair is stored as an undirected edge.
96/// * `loops` — when `true`, the sampler may place self-loop edges
97///   `(u, u)`. When `false`, the diagonal of the pair space is excluded
98///   and every edge connects two distinct vertices.
99/// * `seed` — initialises an internal `SplitMix64` PRNG. Same
100///   `(n, m, directed, loops, seed)` always yields the same graph.
101///
102/// # Errors
103///
104/// Returns [`IgraphError::InvalidArgument`] when:
105///
106/// * `m` exceeds the internal `u32::MAX` edge cap.
107/// * `m > 0` and `n == 0` (no vertices to place edges on).
108/// * `m > 0`, `loops == false`, and `n < 2` (no non-self pairs exist).
109///
110/// # Examples
111///
112/// ```
113/// use rust_igraph::iea_game;
114///
115/// // Directed multigraph: 100 vertices, 500 edges, self-loops allowed.
116/// let g = iea_game(100, 500, true, true, 0xDEAD_BEEF).unwrap();
117/// assert_eq!(g.vcount(), 100);
118/// assert_eq!(g.ecount(), 500);
119/// assert!(g.is_directed());
120/// ```
121pub fn iea_game(n: u32, m: u64, directed: bool, loops: bool, seed: u64) -> IgraphResult<Graph> {
122    validate(n, m, loops)?;
123
124    if m == 0 {
125        return Graph::new(n, directed);
126    }
127
128    let mut rng = SplitMix64::new(seed);
129    let m_usize = usize::try_from(m).map_err(|_| {
130        IgraphError::Internal("iea_game edge count does not fit in usize on this target")
131    })?;
132    let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(m_usize);
133
134    let n_usize = n as usize;
135    if loops {
136        for _ in 0..m {
137            let u = rng.gen_index(n_usize) as VertexId;
138            let v = rng.gen_index(n_usize) as VertexId;
139            edges.push((u, v));
140        }
141    } else {
142        // Sample u uniformly in [0, n), then v uniformly in [0, n-1).
143        // Re-map `v >= u` → `v + 1` to skip the self-loop slot, yielding
144        // a uniform draw over [0, n) \ {u}. This is the natural Rust
145        // analogue of upstream's `if (c == r) c = n-1;` trick — both
146        // achieve a uniform sample of (n-1) distinct values from [0, n)
147        // with a single RNG draw, but the shift-by-one variant keeps the
148        // sampling argument transparent without leaning on the
149        // remap-the-diagonal-row construction the C version uses to
150        // amortise an unrelated rejection trick.
151        let n_minus_1 = n_usize - 1;
152        for _ in 0..m {
153            let u = rng.gen_index(n_usize) as VertexId;
154            let mut v_idx = rng.gen_index(n_minus_1) as u32;
155            if v_idx >= u {
156                v_idx += 1;
157            }
158            edges.push((u, v_idx as VertexId));
159        }
160    }
161
162    let mut g = Graph::new(n, directed)?;
163    g.add_edges(edges)?;
164    Ok(g)
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use std::collections::HashMap;
171
172    fn collect_edges(g: &Graph) -> Vec<(VertexId, VertexId)> {
173        let m = u32::try_from(g.ecount()).expect("ecount fits in u32 in tests");
174        (0..m)
175            .map(|eid| g.edge(eid).expect("edge id in bounds"))
176            .collect()
177    }
178
179    #[test]
180    fn empty_request_returns_empty_graph() {
181        let g = iea_game(50, 0, true, true, 42).unwrap();
182        assert_eq!(g.vcount(), 50);
183        assert_eq!(g.ecount(), 0);
184        assert!(g.is_directed());
185    }
186
187    #[test]
188    fn zero_vertices_with_zero_edges_is_allowed() {
189        let g = iea_game(0, 0, false, false, 7).unwrap();
190        assert_eq!(g.vcount(), 0);
191        assert_eq!(g.ecount(), 0);
192    }
193
194    #[test]
195    fn zero_vertices_with_edges_is_rejected() {
196        let err = iea_game(0, 1, true, true, 0).unwrap_err();
197        match err {
198            IgraphError::InvalidArgument(_) => {}
199            other => panic!("unexpected error: {other:?}"),
200        }
201    }
202
203    #[test]
204    fn no_loops_requires_two_vertices() {
205        let err = iea_game(1, 1, false, false, 1).unwrap_err();
206        assert!(matches!(err, IgraphError::InvalidArgument(_)));
207        // With m=0 the constraint relaxes.
208        let g = iea_game(1, 0, false, false, 1).unwrap();
209        assert_eq!(g.vcount(), 1);
210        assert_eq!(g.ecount(), 0);
211    }
212
213    #[test]
214    fn exact_edge_count_holds_directed_with_loops() {
215        let g = iea_game(80, 1234, true, true, 0xBEEF).unwrap();
216        assert_eq!(g.vcount(), 80);
217        assert_eq!(g.ecount(), 1234);
218        assert!(g.is_directed());
219    }
220
221    #[test]
222    fn exact_edge_count_holds_undirected_no_loops() {
223        let g = iea_game(80, 500, false, false, 0x00C0_FFEE).unwrap();
224        assert_eq!(g.vcount(), 80);
225        assert_eq!(g.ecount(), 500);
226        assert!(!g.is_directed());
227    }
228
229    #[test]
230    fn no_loops_branch_yields_no_self_loops() {
231        let g = iea_game(50, 4000, true, false, 0x9999).unwrap();
232        for (u, v) in collect_edges(&g) {
233            assert_ne!(u, v, "no-loops branch produced self-loop ({u}, {v})");
234        }
235    }
236
237    #[test]
238    fn loops_branch_can_produce_self_loops_eventually() {
239        // For n = 5 with 1000 edges and loops allowed, the expected
240        // number of self-loops is 1000 / 5 = 200 — overwhelmingly likely
241        // to be > 0.
242        let g = iea_game(5, 1000, true, true, 0x1234_5678).unwrap();
243        let any_self = collect_edges(&g).iter().any(|(u, v)| u == v);
244        assert!(any_self, "loops=true should sometimes produce self-loops");
245    }
246
247    #[test]
248    fn deterministic_with_seed() {
249        let a = iea_game(40, 200, true, true, 0xCAFE).unwrap();
250        let b = iea_game(40, 200, true, true, 0xCAFE).unwrap();
251        assert_eq!(collect_edges(&a), collect_edges(&b));
252    }
253
254    #[test]
255    fn different_seeds_yield_different_graphs() {
256        let a = iea_game(40, 200, true, true, 1).unwrap();
257        let b = iea_game(40, 200, true, true, 2).unwrap();
258        assert_ne!(
259            collect_edges(&a),
260            collect_edges(&b),
261            "different seeds must produce different graphs"
262        );
263    }
264
265    #[test]
266    fn endpoint_indices_in_range() {
267        let n = 30u32;
268        let g = iea_game(n, 1000, true, true, 0x42).unwrap();
269        for (u, v) in collect_edges(&g) {
270            assert!(u < n && v < n, "endpoint out of range: ({u}, {v})");
271        }
272    }
273
274    #[test]
275    fn marginal_endpoint_distribution_is_roughly_uniform() {
276        // Each draw uniformly picks an endpoint from [0, n). Across many
277        // samples, the per-vertex hit count should concentrate near the
278        // expected value n_hits / n. We use a relaxed χ²-style check:
279        // every bin must be within ±25% of the mean.
280        let n = 8u32;
281        let m = 200_000u64;
282        let g = iea_game(n, m, true, true, 0xC0DE).unwrap();
283        let mut hits = vec![0u64; n as usize];
284        for (u, v) in collect_edges(&g) {
285            hits[u as usize] += 1;
286            hits[v as usize] += 1;
287        }
288        let expected = (2 * m) as f64 / f64::from(n);
289        for (i, &h) in hits.iter().enumerate() {
290            let dev = ((h as f64) - expected).abs() / expected;
291            assert!(
292                dev < 0.05,
293                "endpoint {i} deviation {dev} exceeds 5%: hits={h}, expected={expected}"
294            );
295        }
296    }
297
298    #[test]
299    fn no_loops_marginal_distribution_is_roughly_uniform() {
300        let n = 8u32;
301        let m = 200_000u64;
302        let g = iea_game(n, m, true, false, 0xD00D).unwrap();
303        let mut hits = vec![0u64; n as usize];
304        for (u, v) in collect_edges(&g) {
305            assert_ne!(u, v);
306            hits[u as usize] += 1;
307            hits[v as usize] += 1;
308        }
309        let expected = (2 * m) as f64 / f64::from(n);
310        for (i, &h) in hits.iter().enumerate() {
311            let dev = ((h as f64) - expected).abs() / expected;
312            assert!(
313                dev < 0.05,
314                "endpoint {i} deviation {dev} exceeds 5%: hits={h}, expected={expected}"
315            );
316        }
317    }
318
319    #[test]
320    fn allows_multi_edges() {
321        // With n=4 and m=1000, the number of edges far exceeds the 16
322        // distinct ordered pairs — multi-edges must appear.
323        let g = iea_game(4, 1000, true, true, 0x1357).unwrap();
324        let mut counts: HashMap<(VertexId, VertexId), u32> = HashMap::new();
325        for e in collect_edges(&g) {
326            *counts.entry(e).or_default() += 1;
327        }
328        let max_mult = counts.values().copied().max().unwrap_or(0);
329        assert!(
330            max_mult > 1,
331            "expected multi-edges with m=1000 on n=4, got max multiplicity {max_mult}"
332        );
333    }
334
335    #[test]
336    fn cap_rejects_overlarge_m() {
337        let err = iea_game(2, u64::from(u32::MAX) + 1, true, true, 0).unwrap_err();
338        assert!(matches!(err, IgraphError::InvalidArgument(_)));
339    }
340}
341
342#[cfg(all(test, feature = "proptest-harness"))]
343mod proptests {
344    use super::*;
345    use proptest::prelude::*;
346
347    proptest! {
348        #![proptest_config(ProptestConfig::with_cases(60))]
349
350        #[test]
351        fn vcount_and_ecount_invariants(
352            n in 2u32..40,
353            m in 0u64..400,
354            directed in any::<bool>(),
355            loops in any::<bool>(),
356            seed in any::<u64>(),
357        ) {
358            let g = iea_game(n, m, directed, loops, seed).unwrap();
359            prop_assert_eq!(g.vcount(), n);
360            prop_assert_eq!(g.ecount(), m as usize);
361            prop_assert_eq!(g.is_directed(), directed);
362        }
363
364        #[test]
365        fn no_loops_branch_has_no_self_loops(
366            n in 2u32..40,
367            m in 1u64..400,
368            directed in any::<bool>(),
369            seed in any::<u64>(),
370        ) {
371            let g = iea_game(n, m, directed, false, seed).unwrap();
372            let m_u32 = u32::try_from(g.ecount()).unwrap();
373            for eid in 0..m_u32 {
374                let (u, v) = g.edge(eid).unwrap();
375                prop_assert_ne!(u, v);
376            }
377        }
378
379        #[test]
380        fn determinism_is_seed_only(
381            n in 2u32..30,
382            m in 0u64..150,
383            directed in any::<bool>(),
384            loops in any::<bool>(),
385            seed in any::<u64>(),
386        ) {
387            let a = iea_game(n, m, directed, loops, seed).unwrap();
388            let b = iea_game(n, m, directed, loops, seed).unwrap();
389            let am = u32::try_from(a.ecount()).unwrap();
390            let bm = u32::try_from(b.ecount()).unwrap();
391            prop_assert_eq!(am, bm);
392            for eid in 0..am {
393                prop_assert_eq!(a.edge(eid).unwrap(), b.edge(eid).unwrap());
394            }
395        }
396
397        #[test]
398        fn endpoints_within_range(
399            n in 2u32..40,
400            m in 0u64..200,
401            directed in any::<bool>(),
402            loops in any::<bool>(),
403            seed in any::<u64>(),
404        ) {
405            let g = iea_game(n, m, directed, loops, seed).unwrap();
406            let m_u32 = u32::try_from(g.ecount()).unwrap();
407            for eid in 0..m_u32 {
408                let (u, v) = g.edge(eid).unwrap();
409                prop_assert!(u < n);
410                prop_assert!(v < n);
411            }
412        }
413    }
414}