Skip to main content

rust_igraph/algorithms/games/
bipartite.rs

1//! Bipartite Erdős–Rényi random graph generators (ALGO-GN-030).
2//!
3//! Counterpart of `igraph_bipartite_game_gnp()` and
4//! `igraph_bipartite_game_gnm()` from
5//! `references/igraph/src/misc/bipartite.c:1646-1784` and `:1365-1415`.
6//!
7//! Two bipartite analogues of the classical Erdős–Rényi models. Every
8//! result graph has `n1 + n2` vertices: the **bottom** partition occupies
9//! ids `0..n1` (`types[v] = false`), the **top** partition occupies ids
10//! `n1..n1+n2` (`types[v] = true`). Bipartite graphs have no self-loops
11//! and no within-partition edges; that constraint is enforced
12//! structurally by the pair-index decoders.
13//!
14//! * **G(n1, n2, p)**: every potential cross-partition edge is included
15//!   independently with probability `p`. Sampled with the
16//!   Batagelj–Brandes 2005 geometric-skip walk so the cost is `O(n1 +
17//!   n2 + |E|)` rather than `O(n1·n2)`.
18//!
19//! * **G(n1, n2, m)**: exactly `m` distinct cross-partition edges drawn
20//!   uniformly at random from the `max_edges(n1, n2, directed, mode)`
21//!   possible. Sampled with Floyd's algorithm for an `O(m)` distinct
22//!   draw.
23//!
24//! ## Edge direction (directed graphs)
25//!
26//! [`BipartiteMode`] mirrors igraph's `IGRAPH_OUT` / `IGRAPH_IN` /
27//! `IGRAPH_ALL`:
28//!
29//! * [`BipartiteMode::Out`] — arcs go bottom → top.
30//! * [`BipartiteMode::In`] — arcs go top → bottom.
31//! * [`BipartiteMode::All`] — each ordered pair is sampled
32//!   independently, so mutual pairs `(b → t, t → b)` are possible. The
33//!   pair space doubles to `2·n1·n2`.
34//!
35//! For undirected graphs the mode argument is ignored; the sampler walks
36//! the same `n1·n2` pair space and stores each edge canonically.
37//!
38//! ## Determinism
39//!
40//! Both functions are deterministic given the `seed` argument and run
41//! against the shared `SplitMix64` PRNG.
42//!
43//! ## Scope
44//!
45//! `gnp` / `gnm` port the most-used path: **simple bipartite graphs**.
46//! [`bipartite_iea_game`] covers the `IGRAPH_MULTI_SW` multigraph model
47//! via independent edge assignment (edges drawn *with replacement*).
48//! Upstream's `IGRAPH_EDGE_LABELED` variant and the `gnp_bipartite_large`
49//! overflow path for `n1·n2 > 2^53` remain out of scope — they will land
50//! as follow-up AWUs if real users ask for them.
51//!
52//! ## References
53//!
54//! * V. Batagelj and U. Brandes, *"Efficient generation of large random
55//!   networks"*, Phys. Rev. E **71**, 036113 (2005).
56//! * R. W. Floyd, *Algorithm 489: The algorithm SELECT …*, CACM (1972).
57
58#![allow(
59    clippy::cast_possible_truncation,
60    clippy::cast_precision_loss,
61    clippy::cast_sign_loss,
62    clippy::float_cmp
63)]
64
65use std::collections::HashSet;
66
67use crate::core::rng::SplitMix64;
68use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
69
70/// Edge-direction policy for the directed variants of the bipartite
71/// Erdős–Rényi models. See module docs for the mode semantics.
72///
73/// `IGRAPH_ALL` translates to [`BipartiteMode::All`]: each ordered pair
74/// `(b, t)` and `(t, b)` is sampled *independently*, so mutual edges
75/// between the same `b` ∈ bottom and `t` ∈ top can be produced.
76#[derive(Clone, Copy, Debug, PartialEq, Eq)]
77pub enum BipartiteMode {
78    /// Arcs from bottom to top. Pair space `n1·n2`.
79    Out,
80    /// Arcs from top to bottom. Pair space `n1·n2`.
81    In,
82    /// Each ordered direction sampled independently. Pair space
83    /// `2·n1·n2`; mutual pairs are possible.
84    All,
85}
86
87/// Result of [`bipartite_game_gnp`] / [`bipartite_game_gnm`]: the
88/// graph together with the boolean type vector that names every vertex
89/// as bottom (`false`) or top (`true`).
90#[derive(Clone, Debug)]
91pub struct BipartiteGraph {
92    /// Graph on `n1 + n2` vertices.
93    pub graph: Graph,
94    /// Per-vertex partition label. `types[v] == false` for
95    /// `v ∈ 0..n1` (bottom), `types[v] == true` for `v ∈ n1..n1+n2`
96    /// (top).
97    pub types: Vec<bool>,
98}
99
100/// Number of *possible* edges for a bipartite graph on `n1` bottom and
101/// `n2` top vertices. Returned as `u64` so callers walking pair-index
102/// space don't silently truncate.
103fn max_edges(n1: u32, n2: u32, directed: bool, mode: BipartiteMode) -> u64 {
104    let prod = u64::from(n1) * u64::from(n2);
105    if directed && matches!(mode, BipartiteMode::All) {
106        prod.saturating_mul(2)
107    } else {
108        prod
109    }
110}
111
112/// Build the type vector for a bipartite graph: `n1` bottom vertices
113/// labelled `false`, then `n2` top vertices labelled `true`.
114fn types_vector(n1: u32, n2: u32) -> Vec<bool> {
115    let total = (n1 as usize).saturating_add(n2 as usize);
116    let mut v = Vec::with_capacity(total);
117    v.resize(n1 as usize, false);
118    v.resize(total, true);
119    v
120}
121
122/// Decode a linear pair-index `idx` into a directed `(from, to)` arc
123/// already mapped into the joint vertex id space `[0, n1+n2)`.
124///
125/// The decoder mirrors `references/igraph/src/misc/bipartite.c:1746-1762`
126/// byte-for-byte. For directed-with-ALL the first `n1·n2` indices encode
127/// bottom→top arcs and the remaining `n1·n2` encode top→bottom arcs.
128fn decode_pair(
129    idx: u64,
130    n1: u32,
131    n2: u32,
132    directed: bool,
133    mode: BipartiteMode,
134) -> (VertexId, VertexId) {
135    let n1_u64 = u64::from(n1);
136    let n2_u64 = u64::from(n2);
137    let n1n2 = n1_u64 * n2_u64;
138
139    let (from, to) = if !directed || !matches!(mode, BipartiteMode::All) {
140        // Single rectangular cell of size n1 × n2.
141        // to walks the top partition, from walks the bottom.
142        let to_off = idx / n1_u64;
143        let from_off = idx - to_off * n1_u64;
144        debug_assert!(from_off < n1_u64 && to_off < n2_u64);
145        (from_off, to_off + n1_u64)
146    } else if idx < n1n2 {
147        // Directed + ALL, bottom→top half.
148        let to_off = idx / n1_u64;
149        let from_off = idx - to_off * n1_u64;
150        debug_assert!(from_off < n1_u64 && to_off < n2_u64);
151        (from_off, to_off + n1_u64)
152    } else {
153        // Directed + ALL, top→bottom half.
154        let rel = idx - n1n2;
155        let to_off = rel / n2_u64;
156        let from_off = rel - to_off * n2_u64;
157        debug_assert!(from_off < n2_u64 && to_off < n1_u64);
158        (from_off + n1_u64, to_off)
159    };
160
161    // For mode == In we swap so that the recorded arc points top→bottom.
162    let (from, to) = if matches!(mode, BipartiteMode::In) && directed {
163        (to, from)
164    } else {
165        (from, to)
166    };
167
168    #[allow(clippy::cast_possible_truncation)]
169    (from as VertexId, to as VertexId)
170}
171
172/// Sample `m` distinct integers from `[0, n_pairs)` using Floyd's
173/// algorithm. Same routine as in `erdos_renyi`, duplicated here to keep
174/// per-module independence (we don't want the bipartite module to
175/// reach across `super::erdos_renyi` for a private helper).
176fn distinct_sample(rng: &mut SplitMix64, n_pairs: u64, m: u64) -> Vec<u64> {
177    debug_assert!(m <= n_pairs);
178    let cap = usize::try_from(m).unwrap_or(usize::MAX);
179    let mut chosen: HashSet<u64> = HashSet::with_capacity(cap);
180    let mut out: Vec<u64> = Vec::with_capacity(cap);
181    for j in (n_pairs - m)..n_pairs {
182        let span = j + 1;
183        let span_usize = usize::try_from(span).unwrap_or(usize::MAX);
184        let t_usize = rng.gen_index(span_usize);
185        let t = t_usize as u64;
186        let pick = if chosen.insert(t) {
187            t
188        } else {
189            chosen.insert(j);
190            j
191        };
192        out.push(pick);
193    }
194    out.sort_unstable();
195    out
196}
197
198fn validate_gnp(p: f64) -> IgraphResult<()> {
199    if !p.is_finite() {
200        return Err(IgraphError::InvalidArgument(format!(
201            "bipartite G(n,p) probability must be finite (got {p})"
202        )));
203    }
204    if !(0.0..=1.0).contains(&p) {
205        return Err(IgraphError::InvalidArgument(format!(
206            "bipartite G(n,p) probability must be in [0, 1] (got {p})"
207        )));
208    }
209    Ok(())
210}
211
212fn validate_gnm(n1: u32, n2: u32, m: u64, directed: bool, mode: BipartiteMode) -> IgraphResult<()> {
213    let cap = max_edges(n1, n2, directed, mode);
214    if m > cap {
215        return Err(IgraphError::InvalidArgument(format!(
216            "bipartite G(n,m) requested {m} edges but the {} graph on {n1}+{n2} vertices admits at most {cap}",
217            if directed { "directed" } else { "undirected" }
218        )));
219    }
220    if cap == 0 && m > 0 {
221        return Err(IgraphError::InvalidArgument(format!(
222            "bipartite G(n,m) requested {m} edges but n1={n1} or n2={n2} is zero"
223        )));
224    }
225    Ok(())
226}
227
228/// Build the result from an already-decoded edge list.
229fn finalize(
230    n1: u32,
231    n2: u32,
232    directed: bool,
233    edges: Vec<(VertexId, VertexId)>,
234) -> IgraphResult<BipartiteGraph> {
235    let n_total = u32::try_from(u64::from(n1) + u64::from(n2)).map_err(|_| {
236        IgraphError::InvalidArgument(format!("n1 + n2 overflows u32 (n1={n1}, n2={n2})"))
237    })?;
238    let mut g = Graph::new(n_total, directed)?;
239    g.add_edges(edges)?;
240    Ok(BipartiteGraph {
241        graph: g,
242        types: types_vector(n1, n2),
243    })
244}
245
246/// Build the complete bipartite graph K_{n1, n2}, with arc orientation
247/// determined by `(directed, mode)`. Used for the `p == 1` fast path of
248/// `bipartite_game_gnp` and the `m == max_edges` fast path of `_gnm`.
249fn complete_bipartite(
250    n1: u32,
251    n2: u32,
252    directed: bool,
253    mode: BipartiteMode,
254) -> IgraphResult<BipartiteGraph> {
255    let cap = max_edges(n1, n2, directed, mode);
256    let cap_usize = usize::try_from(cap)
257        .map_err(|_| IgraphError::Internal("complete bipartite edge count exceeds usize"))?;
258    let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(cap_usize);
259    for idx in 0..cap {
260        edges.push(decode_pair(idx, n1, n2, directed, mode));
261    }
262    finalize(n1, n2, directed, edges)
263}
264
265/// Generate a random bipartite graph from the **G(n1, n2, p)** model.
266///
267/// Every possible cross-partition edge is included independently with
268/// probability `p`. The expected number of edges is
269/// `p · max_edges(n1, n2, directed, mode)`.
270///
271/// * `n1` — bottom partition size (vertices `0..n1`).
272/// * `n2` — top partition size (vertices `n1..n1+n2`).
273/// * `p ∈ [0, 1]` — edge probability.
274/// * `directed` — generate a directed bipartite graph; when `false`
275///   edges are undirected and `mode` is effectively `All` (pair space
276///   `n1·n2`, no mutual pairs because the graph is undirected).
277/// * `mode` — see [`BipartiteMode`]. Ignored when `directed == false`.
278/// * `seed` — initialises an internal `SplitMix64` PRNG. The same
279///   `(n1, n2, p, directed, mode, seed)` always yields the same graph.
280///
281/// # Errors
282///
283/// Returns [`IgraphError::InvalidArgument`] if `p` is NaN, infinite, or
284/// outside `[0, 1]`, or if `n1 + n2` overflows `u32`.
285///
286/// # Examples
287///
288/// ```
289/// use rust_igraph::{bipartite_game_gnp, BipartiteMode};
290/// // Expected edges ≈ 0.3 · 5 · 8 = 12.
291/// let bg = bipartite_game_gnp(5, 8, 0.3, false, BipartiteMode::All, 42).unwrap();
292/// assert_eq!(bg.graph.vcount(), 13);
293/// assert_eq!(bg.types.len(), 13);
294/// // Every edge crosses the partition.
295/// for eid in 0..bg.graph.ecount() {
296///     let (u, v) = bg.graph.edge(eid as u32).unwrap();
297///     assert_ne!(bg.types[u as usize], bg.types[v as usize]);
298/// }
299/// ```
300pub fn bipartite_game_gnp(
301    n1: u32,
302    n2: u32,
303    p: f64,
304    directed: bool,
305    mode: BipartiteMode,
306    seed: u64,
307) -> IgraphResult<BipartiteGraph> {
308    validate_gnp(p)?;
309
310    let n_total = u32::try_from(u64::from(n1) + u64::from(n2)).map_err(|_| {
311        IgraphError::InvalidArgument(format!("n1 + n2 overflows u32 (n1={n1}, n2={n2})"))
312    })?;
313
314    let cap = max_edges(n1, n2, directed, mode);
315    if cap == 0 || p == 0.0 {
316        let g = Graph::new(n_total, directed)?;
317        return Ok(BipartiteGraph {
318            graph: g,
319            types: types_vector(n1, n2),
320        });
321    }
322    if p == 1.0 {
323        return complete_bipartite(n1, n2, directed, mode);
324    }
325
326    let mut rng = SplitMix64::new(seed);
327    // Batagelj–Brandes geometric-skip walk over the linear pair-index
328    // space [0, cap). At each step, advance by `RNG_GEOM(p) + 1` (the
329    // +1 enforces strictly-increasing indices so the sample is a
330    // *simple* bipartite graph).
331    #[allow(clippy::cast_precision_loss)]
332    let cap_f = cap as f64;
333    let mut last = rng.gen_geom(p);
334    let mut indices: Vec<u64> = Vec::new();
335    while last < cap_f {
336        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
337        let idx = last.trunc() as u64;
338        if idx >= cap {
339            break;
340        }
341        indices.push(idx);
342        last += rng.gen_geom(p);
343        last += 1.0; // simple-graph step
344    }
345
346    let edges: Vec<(VertexId, VertexId)> = indices
347        .into_iter()
348        .map(|idx| decode_pair(idx, n1, n2, directed, mode))
349        .collect();
350    finalize(n1, n2, directed, edges)
351}
352
353/// Generate a random bipartite graph from the **G(n1, n2, m)** model.
354///
355/// Exactly `m` cross-partition edges are drawn uniformly at random
356/// from the `max_edges(n1, n2, directed, mode)` possible ones.
357/// Sampling is without replacement (simple bipartite graph).
358///
359/// * `n1`, `n2`, `directed`, `mode`, `seed` — see [`bipartite_game_gnp`].
360/// * `m` — exact edge count.
361///
362/// # Errors
363///
364/// Returns [`IgraphError::InvalidArgument`] if `m` exceeds the
365/// `max_edges(n1, n2, directed, mode)` capacity, or if `n1 + n2`
366/// overflows `u32`.
367///
368/// # Examples
369///
370/// ```
371/// use rust_igraph::{bipartite_game_gnm, BipartiteMode};
372/// let bg = bipartite_game_gnm(4, 6, 10, false, BipartiteMode::All, 7).unwrap();
373/// assert_eq!(bg.graph.vcount(), 10);
374/// assert_eq!(bg.graph.ecount(), 10);
375/// // Every edge crosses the partition.
376/// for eid in 0..bg.graph.ecount() {
377///     let (u, v) = bg.graph.edge(eid as u32).unwrap();
378///     assert_ne!(bg.types[u as usize], bg.types[v as usize]);
379/// }
380/// ```
381pub fn bipartite_game_gnm(
382    n1: u32,
383    n2: u32,
384    m: u64,
385    directed: bool,
386    mode: BipartiteMode,
387    seed: u64,
388) -> IgraphResult<BipartiteGraph> {
389    validate_gnm(n1, n2, m, directed, mode)?;
390
391    let n_total = u32::try_from(u64::from(n1) + u64::from(n2)).map_err(|_| {
392        IgraphError::InvalidArgument(format!("n1 + n2 overflows u32 (n1={n1}, n2={n2})"))
393    })?;
394
395    if m == 0 {
396        let g = Graph::new(n_total, directed)?;
397        return Ok(BipartiteGraph {
398            graph: g,
399            types: types_vector(n1, n2),
400        });
401    }
402
403    let cap = max_edges(n1, n2, directed, mode);
404    if m == cap {
405        return complete_bipartite(n1, n2, directed, mode);
406    }
407
408    let mut rng = SplitMix64::new(seed);
409    let picks = distinct_sample(&mut rng, cap, m);
410    let edges: Vec<(VertexId, VertexId)> = picks
411        .into_iter()
412        .map(|idx| decode_pair(idx, n1, n2, directed, mode))
413        .collect();
414    finalize(n1, n2, directed, edges)
415}
416
417/// Generate a random bipartite **multigraph** through independent edge
418/// assignment (IEA).
419///
420/// Counterpart of `igraph_bipartite_iea_game()` from
421/// `references/igraph/src/misc/bipartite.c:1476`. Each of the `m` edges is
422/// assigned, independently and uniformly at random, to one of the
423/// `max_edges(n1, n2, directed, mode)` possible cross-partition vertex
424/// pairs. Because draws are *with replacement*, the result may contain
425/// parallel edges — unlike [`bipartite_game_gnm`], which samples `m`
426/// distinct edges.
427///
428/// This model does **not** sample multigraphs uniformly: a multigraph is
429/// produced with probability proportional to `(prod A_ij!)^(-1)`, so all
430/// simple graphs share one probability while non-simple ones are
431/// down-weighted by their edge multiplicities. See [`bipartite_game_gnm`]
432/// for uniform sampling of simple bipartite graphs.
433///
434/// * `n1`, `n2`, `directed`, `mode`, `seed` — see [`bipartite_game_gnp`].
435/// * `m` — exact edge count (no upper bound beyond `u64`; multi-edges are
436///   allowed, so `m` may exceed `max_edges`).
437///
438/// # Errors
439///
440/// Returns [`IgraphError::InvalidArgument`] if `n1 + n2` overflows `u32`,
441/// or if `m > 0` while the pair space is empty (`n1 == 0` or `n2 == 0`).
442///
443/// # Examples
444///
445/// ```
446/// use rust_igraph::{bipartite_iea_game, BipartiteMode};
447/// let bg = bipartite_iea_game(3, 4, 20, false, BipartiteMode::All, 11).unwrap();
448/// assert_eq!(bg.graph.vcount(), 7);
449/// assert_eq!(bg.graph.ecount(), 20); // multi-edges allowed → exactly m edges
450/// // Every edge crosses the partition.
451/// for eid in 0..bg.graph.ecount() {
452///     let (u, v) = bg.graph.edge(eid as u32).unwrap();
453///     assert_ne!(bg.types[u as usize], bg.types[v as usize]);
454/// }
455/// ```
456pub fn bipartite_iea_game(
457    n1: u32,
458    n2: u32,
459    m: u64,
460    directed: bool,
461    mode: BipartiteMode,
462    seed: u64,
463) -> IgraphResult<BipartiteGraph> {
464    let n_total = u32::try_from(u64::from(n1) + u64::from(n2)).map_err(|_| {
465        IgraphError::InvalidArgument(format!("n1 + n2 overflows u32 (n1={n1}, n2={n2})"))
466    })?;
467
468    if m == 0 {
469        let g = Graph::new(n_total, directed)?;
470        return Ok(BipartiteGraph {
471            graph: g,
472            types: types_vector(n1, n2),
473        });
474    }
475
476    let cap = max_edges(n1, n2, directed, mode);
477    if cap == 0 {
478        return Err(IgraphError::InvalidArgument(format!(
479            "bipartite IEA requested {m} edges but n1={n1} or n2={n2} is zero"
480        )));
481    }
482
483    let cap_usize = usize::try_from(cap)
484        .map_err(|_| IgraphError::Internal("bipartite IEA pair space exceeds usize"))?;
485    let m_usize = usize::try_from(m)
486        .map_err(|_| IgraphError::Internal("bipartite IEA edge count exceeds usize"))?;
487
488    let mut rng = SplitMix64::new(seed);
489    let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(m_usize);
490    for _ in 0..m {
491        let idx = rng.gen_index(cap_usize) as u64;
492        edges.push(decode_pair(idx, n1, n2, directed, mode));
493    }
494    finalize(n1, n2, directed, edges)
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use std::collections::HashSet;
501
502    // ------- types_vector / max_edges sanity -------
503
504    #[test]
505    fn types_vector_bottom_then_top() {
506        let t = types_vector(3, 5);
507        assert_eq!(t.len(), 8);
508        for (i, &ty) in t.iter().enumerate() {
509            if i < 3 {
510                assert!(!ty, "vertex {i} should be bottom (false)");
511            } else {
512                assert!(ty, "vertex {i} should be top (true)");
513            }
514        }
515    }
516
517    #[test]
518    fn max_edges_undirected_is_product() {
519        assert_eq!(max_edges(3, 5, false, BipartiteMode::All), 15);
520        assert_eq!(max_edges(0, 5, false, BipartiteMode::All), 0);
521        assert_eq!(max_edges(3, 0, false, BipartiteMode::Out), 0);
522    }
523
524    #[test]
525    fn max_edges_directed_out_or_in_is_product() {
526        assert_eq!(max_edges(3, 5, true, BipartiteMode::Out), 15);
527        assert_eq!(max_edges(3, 5, true, BipartiteMode::In), 15);
528    }
529
530    #[test]
531    fn max_edges_directed_all_doubles() {
532        assert_eq!(max_edges(3, 5, true, BipartiteMode::All), 30);
533    }
534
535    // ------- decode_pair sanity -------
536
537    #[test]
538    fn decode_undirected_covers_every_cross_pair_once() {
539        let (n1, n2) = (3u32, 4u32);
540        let cap = max_edges(n1, n2, false, BipartiteMode::All);
541        let mut seen: HashSet<(u32, u32)> = HashSet::new();
542        for idx in 0..cap {
543            let (u, v) = decode_pair(idx, n1, n2, false, BipartiteMode::All);
544            assert!(u < n1 && v >= n1 && v < n1 + n2);
545            assert!(seen.insert((u, v)), "duplicate at idx {idx}: {u},{v}");
546        }
547        assert_eq!(seen.len(), (n1 * n2) as usize);
548    }
549
550    #[test]
551    fn decode_directed_out_emits_bottom_to_top() {
552        let (n1, n2) = (3u32, 4u32);
553        let cap = max_edges(n1, n2, true, BipartiteMode::Out);
554        for idx in 0..cap {
555            let (u, v) = decode_pair(idx, n1, n2, true, BipartiteMode::Out);
556            assert!(u < n1, "from must be bottom, got {u}");
557            assert!(v >= n1 && v < n1 + n2, "to must be top, got {v}");
558        }
559    }
560
561    #[test]
562    fn decode_directed_in_emits_top_to_bottom() {
563        let (n1, n2) = (3u32, 4u32);
564        let cap = max_edges(n1, n2, true, BipartiteMode::In);
565        for idx in 0..cap {
566            let (u, v) = decode_pair(idx, n1, n2, true, BipartiteMode::In);
567            assert!(u >= n1 && u < n1 + n2, "from must be top, got {u}");
568            assert!(v < n1, "to must be bottom, got {v}");
569        }
570    }
571
572    #[test]
573    fn decode_directed_all_emits_each_ordered_pair_once() {
574        let (n1, n2) = (3u32, 4u32);
575        let cap = max_edges(n1, n2, true, BipartiteMode::All);
576        let mut seen: HashSet<(u32, u32)> = HashSet::new();
577        for idx in 0..cap {
578            let (u, v) = decode_pair(idx, n1, n2, true, BipartiteMode::All);
579            // Crosses partition, in either direction.
580            let u_bot = u < n1;
581            let v_bot = v < n1;
582            assert_ne!(u_bot, v_bot, "endpoints must cross partition");
583            assert!(seen.insert((u, v)), "duplicate ordered pair at idx {idx}");
584        }
585        assert_eq!(seen.len(), 2 * (n1 * n2) as usize);
586    }
587
588    // ------- gnp boundary cases -------
589
590    #[test]
591    fn gnp_p_zero_is_empty() {
592        let bg = bipartite_game_gnp(5, 6, 0.0, false, BipartiteMode::All, 1).unwrap();
593        assert_eq!(bg.graph.vcount(), 11);
594        assert_eq!(bg.graph.ecount(), 0);
595    }
596
597    #[test]
598    fn gnp_p_one_undirected_is_complete_bipartite() {
599        let bg = bipartite_game_gnp(3, 4, 1.0, false, BipartiteMode::All, 1).unwrap();
600        assert_eq!(bg.graph.ecount(), 12);
601    }
602
603    #[test]
604    fn gnp_p_one_directed_out_is_complete_bipartite() {
605        let bg = bipartite_game_gnp(3, 4, 1.0, true, BipartiteMode::Out, 1).unwrap();
606        assert_eq!(bg.graph.ecount(), 12);
607        for eid in 0..bg.graph.ecount() {
608            let (u, v) = bg.graph.edge(eid as u32).unwrap();
609            assert!(u < 3, "Out arc must start in bottom");
610            assert!(v >= 3, "Out arc must end in top");
611        }
612    }
613
614    #[test]
615    fn gnp_p_one_directed_all_doubles_edge_count() {
616        let bg = bipartite_game_gnp(3, 4, 1.0, true, BipartiteMode::All, 1).unwrap();
617        assert_eq!(bg.graph.ecount(), 24);
618    }
619
620    #[test]
621    fn gnp_invalid_p_rejected() {
622        assert!(bipartite_game_gnp(3, 4, -0.1, false, BipartiteMode::All, 1).is_err());
623        assert!(bipartite_game_gnp(3, 4, 1.1, false, BipartiteMode::All, 1).is_err());
624        assert!(bipartite_game_gnp(3, 4, f64::NAN, false, BipartiteMode::All, 1).is_err());
625        assert!(bipartite_game_gnp(3, 4, f64::INFINITY, false, BipartiteMode::All, 1).is_err());
626    }
627
628    #[test]
629    fn gnp_zero_n1_is_edgeless_with_n2_vertices() {
630        let bg = bipartite_game_gnp(0, 5, 0.5, false, BipartiteMode::All, 1).unwrap();
631        assert_eq!(bg.graph.vcount(), 5);
632        assert_eq!(bg.graph.ecount(), 0);
633        for &t in &bg.types {
634            assert!(t, "all 5 must be top");
635        }
636    }
637
638    #[test]
639    fn gnp_zero_n2_is_edgeless_with_n1_vertices() {
640        let bg = bipartite_game_gnp(7, 0, 0.5, false, BipartiteMode::All, 1).unwrap();
641        assert_eq!(bg.graph.vcount(), 7);
642        assert_eq!(bg.graph.ecount(), 0);
643        for &t in &bg.types {
644            assert!(!t, "all 7 must be bottom");
645        }
646    }
647
648    #[test]
649    fn gnp_deterministic_with_seed() {
650        let a = bipartite_game_gnp(10, 15, 0.4, false, BipartiteMode::All, 12345).unwrap();
651        let b = bipartite_game_gnp(10, 15, 0.4, false, BipartiteMode::All, 12345).unwrap();
652        assert_eq!(a.graph.vcount(), b.graph.vcount());
653        assert_eq!(a.graph.ecount(), b.graph.ecount());
654        let edges_a: Vec<_> = (0..a.graph.ecount())
655            .map(|e| a.graph.edge(e as u32).unwrap())
656            .collect();
657        let edges_b: Vec<_> = (0..b.graph.ecount())
658            .map(|e| b.graph.edge(e as u32).unwrap())
659            .collect();
660        assert_eq!(edges_a, edges_b);
661    }
662
663    #[test]
664    fn gnp_different_seeds_typically_differ() {
665        let a = bipartite_game_gnp(40, 60, 0.05, false, BipartiteMode::All, 1).unwrap();
666        let b = bipartite_game_gnp(40, 60, 0.05, false, BipartiteMode::All, 2).unwrap();
667        assert_ne!(a.graph.ecount(), b.graph.ecount());
668    }
669
670    #[test]
671    fn gnp_expected_ecount_in_band() {
672        // E[m] = p · n1·n2 = 0.2 · 30·30 = 180; stddev ≈ sqrt(np(1-p))
673        // ≈ sqrt(180·0.8) ≈ 12; ±60 is ~5σ — generous, catches a
674        // broken sampler.
675        let bg = bipartite_game_gnp(30, 30, 0.2, false, BipartiteMode::All, 31_415).unwrap();
676        let m = bg.graph.ecount();
677        assert!(m > 120 && m < 240, "ecount = {m}");
678    }
679
680    #[test]
681    fn gnp_is_simple_and_bipartite() {
682        let bg = bipartite_game_gnp(20, 20, 0.3, false, BipartiteMode::All, 7).unwrap();
683        let mut seen: HashSet<(u32, u32)> = HashSet::new();
684        for e in 0..bg.graph.ecount() {
685            let (u, v) = bg.graph.edge(e as u32).unwrap();
686            assert_ne!(u, v, "self-loop in bipartite output");
687            // Must cross partition.
688            assert_ne!(bg.types[u as usize], bg.types[v as usize]);
689            let canon = if u <= v { (u, v) } else { (v, u) };
690            assert!(seen.insert(canon), "parallel edge {canon:?}");
691        }
692    }
693
694    #[test]
695    fn gnp_directed_in_arcs_top_to_bottom() {
696        let bg = bipartite_game_gnp(5, 6, 0.5, true, BipartiteMode::In, 999).unwrap();
697        for e in 0..bg.graph.ecount() {
698            let (u, v) = bg.graph.edge(e as u32).unwrap();
699            assert!((5..11).contains(&u), "In source must be top, got {u}");
700            assert!(v < 5, "In target must be bottom, got {v}");
701        }
702    }
703
704    // ------- gnm boundary cases -------
705
706    #[test]
707    fn gnm_m_zero_is_empty() {
708        let bg = bipartite_game_gnm(5, 6, 0, false, BipartiteMode::All, 1).unwrap();
709        assert_eq!(bg.graph.vcount(), 11);
710        assert_eq!(bg.graph.ecount(), 0);
711    }
712
713    #[test]
714    fn gnm_m_max_is_complete_bipartite() {
715        let bg = bipartite_game_gnm(3, 4, 12, false, BipartiteMode::All, 1).unwrap();
716        assert_eq!(bg.graph.ecount(), 12);
717    }
718
719    #[test]
720    fn gnm_m_max_directed_all_doubles() {
721        let bg = bipartite_game_gnm(3, 4, 24, true, BipartiteMode::All, 1).unwrap();
722        assert_eq!(bg.graph.ecount(), 24);
723    }
724
725    #[test]
726    fn gnm_m_exceeds_capacity_rejected() {
727        assert!(bipartite_game_gnm(5, 6, 31, false, BipartiteMode::All, 1).is_err());
728    }
729
730    #[test]
731    fn gnm_zero_partition_with_m_positive_is_error() {
732        assert!(bipartite_game_gnm(0, 5, 1, false, BipartiteMode::All, 1).is_err());
733        assert!(bipartite_game_gnm(5, 0, 1, false, BipartiteMode::All, 1).is_err());
734    }
735
736    #[test]
737    fn gnm_exact_edge_count() {
738        let bg = bipartite_game_gnm(10, 20, 50, false, BipartiteMode::All, 42).unwrap();
739        assert_eq!(bg.graph.ecount(), 50);
740        assert_eq!(bg.graph.vcount(), 30);
741    }
742
743    #[test]
744    fn gnm_deterministic_with_seed() {
745        let a = bipartite_game_gnm(15, 20, 80, false, BipartiteMode::All, 7).unwrap();
746        let b = bipartite_game_gnm(15, 20, 80, false, BipartiteMode::All, 7).unwrap();
747        let edges_a: Vec<_> = (0..a.graph.ecount())
748            .map(|e| a.graph.edge(e as u32).unwrap())
749            .collect();
750        let edges_b: Vec<_> = (0..b.graph.ecount())
751            .map(|e| b.graph.edge(e as u32).unwrap())
752            .collect();
753        assert_eq!(edges_a, edges_b);
754    }
755
756    #[test]
757    fn gnm_is_simple_and_bipartite() {
758        let bg = bipartite_game_gnm(12, 10, 40, false, BipartiteMode::All, 99).unwrap();
759        let mut seen: HashSet<(u32, u32)> = HashSet::new();
760        for e in 0..bg.graph.ecount() {
761            let (u, v) = bg.graph.edge(e as u32).unwrap();
762            assert_ne!(u, v);
763            assert_ne!(bg.types[u as usize], bg.types[v as usize]);
764            let canon = if u <= v { (u, v) } else { (v, u) };
765            assert!(seen.insert(canon), "parallel edge {canon:?}");
766        }
767    }
768
769    #[test]
770    fn gnm_directed_out_arcs_bottom_to_top() {
771        let bg = bipartite_game_gnm(4, 5, 15, true, BipartiteMode::Out, 33).unwrap();
772        assert_eq!(bg.graph.ecount(), 15);
773        for e in 0..bg.graph.ecount() {
774            let (u, v) = bg.graph.edge(e as u32).unwrap();
775            assert!(u < 4, "Out source must be bottom, got {u}");
776            assert!((4..9).contains(&v), "Out target must be top, got {v}");
777        }
778    }
779
780    #[test]
781    fn gnm_directed_all_allows_mutual_pairs() {
782        // m=24 = 2*3*4 forces every ordered cross-pair to appear, so the
783        // canonical-pair (lo, hi) multiset has each unordered cross
784        // pair exactly twice.
785        let bg = bipartite_game_gnm(3, 4, 24, true, BipartiteMode::All, 1).unwrap();
786        let mut canonical_counts: std::collections::HashMap<(u32, u32), u32> =
787            std::collections::HashMap::new();
788        for e in 0..bg.graph.ecount() {
789            let (u, v) = bg.graph.edge(e as u32).unwrap();
790            let key = if u <= v { (u, v) } else { (v, u) };
791            *canonical_counts.entry(key).or_insert(0) += 1;
792        }
793        for (_, c) in canonical_counts {
794            assert_eq!(
795                c, 2,
796                "every unordered cross pair appears twice in K_(3,4)·2"
797            );
798        }
799    }
800
801    #[test]
802    fn gnp_full_directed_all_is_bipartite_and_has_2n1n2_edges() {
803        // Sanity: p=1 with directed+All must include both
804        // (bottom_i, top_j) and (top_j, bottom_i) — 2 · n1 · n2 arcs.
805        let bg = bipartite_game_gnp(2, 3, 1.0, true, BipartiteMode::All, 0).unwrap();
806        assert_eq!(bg.graph.ecount(), 12);
807        let mut bottom_to_top = 0u32;
808        let mut top_to_bottom = 0u32;
809        for e in 0..bg.graph.ecount() {
810            let (u, v) = bg.graph.edge(e as u32).unwrap();
811            if u < 2 && v >= 2 {
812                bottom_to_top += 1;
813            } else if u >= 2 && v < 2 {
814                top_to_bottom += 1;
815            } else {
816                panic!("non-bipartite arc {u}->{v}");
817            }
818        }
819        assert_eq!(bottom_to_top, 6);
820        assert_eq!(top_to_bottom, 6);
821    }
822
823    // ------- bipartite_iea_game -------
824
825    #[test]
826    fn iea_m_zero_is_empty() {
827        let bg = bipartite_iea_game(3, 4, 0, false, BipartiteMode::All, 1).unwrap();
828        assert_eq!(bg.graph.vcount(), 7);
829        assert_eq!(bg.graph.ecount(), 0);
830    }
831
832    #[test]
833    fn iea_exact_edge_count_with_multiplicity() {
834        // m may exceed max_edges because parallel edges are allowed.
835        let cap = max_edges(2, 2, false, BipartiteMode::All); // = 4
836        let m = cap + 10;
837        let bg = bipartite_iea_game(2, 2, m, false, BipartiteMode::All, 9).unwrap();
838        assert_eq!(bg.graph.ecount() as u64, m);
839    }
840
841    #[test]
842    fn iea_zero_partition_with_m_positive_is_error() {
843        assert!(bipartite_iea_game(0, 5, 3, false, BipartiteMode::All, 0).is_err());
844        assert!(bipartite_iea_game(5, 0, 3, false, BipartiteMode::Out, 0).is_err());
845    }
846
847    #[test]
848    fn iea_all_edges_cross_partition() {
849        let bg = bipartite_iea_game(4, 3, 30, false, BipartiteMode::All, 42).unwrap();
850        assert_eq!(bg.graph.ecount(), 30);
851        for e in 0..bg.graph.ecount() {
852            let (u, v) = bg.graph.edge(e as u32).unwrap();
853            assert_ne!(bg.types[u as usize], bg.types[v as usize]);
854        }
855    }
856
857    #[test]
858    fn iea_deterministic_with_seed() {
859        let a = bipartite_iea_game(5, 6, 25, true, BipartiteMode::Out, 7).unwrap();
860        let b = bipartite_iea_game(5, 6, 25, true, BipartiteMode::Out, 7).unwrap();
861        assert_eq!(a.graph.ecount(), b.graph.ecount());
862        for e in 0..a.graph.ecount() {
863            assert_eq!(
864                a.graph.edge(e as u32).unwrap(),
865                b.graph.edge(e as u32).unwrap()
866            );
867        }
868    }
869
870    #[test]
871    fn iea_directed_out_arcs_bottom_to_top() {
872        let bg = bipartite_iea_game(3, 3, 40, true, BipartiteMode::Out, 3).unwrap();
873        for e in 0..bg.graph.ecount() {
874            let (u, v) = bg.graph.edge(e as u32).unwrap();
875            assert!(u < 3 && v >= 3, "arc {u}->{v} is not bottom→top");
876        }
877    }
878
879    #[test]
880    fn iea_directed_in_arcs_top_to_bottom() {
881        let bg = bipartite_iea_game(3, 3, 40, true, BipartiteMode::In, 3).unwrap();
882        for e in 0..bg.graph.ecount() {
883            let (u, v) = bg.graph.edge(e as u32).unwrap();
884            assert!(u >= 3 && v < 3, "arc {u}->{v} is not top→bottom");
885        }
886    }
887
888    #[test]
889    fn iea_likely_produces_multi_edge() {
890        // With m far exceeding capacity, a parallel edge is essentially
891        // certain; verify at least one repeats.
892        let bg = bipartite_iea_game(2, 2, 50, false, BipartiteMode::All, 5).unwrap();
893        let mut seen: HashSet<(u32, u32)> = HashSet::new();
894        let mut found_dup = false;
895        for e in 0..bg.graph.ecount() {
896            let (u, v) = bg.graph.edge(e as u32).unwrap();
897            let key = if u <= v { (u, v) } else { (v, u) };
898            if !seen.insert(key) {
899                found_dup = true;
900            }
901        }
902        assert!(found_dup, "expected a parallel edge with m=50 over 4 pairs");
903    }
904
905    // ------- proptest harness (gated; runs under `cargo test --features proptest-harness`) -------
906
907    #[cfg(all(test, feature = "proptest-harness"))]
908    mod prop {
909        use super::super::*;
910        use proptest::prelude::*;
911
912        fn arb_mode() -> impl Strategy<Value = BipartiteMode> {
913            prop_oneof![
914                Just(BipartiteMode::Out),
915                Just(BipartiteMode::In),
916                Just(BipartiteMode::All),
917            ]
918        }
919
920        proptest! {
921            #[test]
922            fn gnp_vcount_always_matches_sum(
923                n1 in 0u32..15,
924                n2 in 0u32..15,
925                p in 0.0..=1.0,
926                directed in any::<bool>(),
927                mode in arb_mode(),
928                seed in any::<u64>(),
929            ) {
930                let bg = bipartite_game_gnp(n1, n2, p, directed, mode, seed).unwrap();
931                prop_assert_eq!(bg.graph.vcount(), n1 + n2);
932                prop_assert_eq!(bg.types.len() as u32, n1 + n2);
933                prop_assert!(bg.graph.ecount() as u64 <= max_edges(n1, n2, directed, mode));
934            }
935
936            #[test]
937            fn gnp_simple_and_bipartite(
938                n1 in 1u32..12,
939                n2 in 1u32..12,
940                p in 0.0..=0.6,
941                directed in any::<bool>(),
942                mode in arb_mode(),
943                seed in any::<u64>(),
944            ) {
945                let bg = bipartite_game_gnp(n1, n2, p, directed, mode, seed).unwrap();
946                // Distinct-edge multiset: for undirected use canonical
947                // (lo, hi); for directed All allow mutual pairs so
948                // dedup on ordered pair instead.
949                let mut seen: std::collections::HashSet<(u32, u32)> =
950                    std::collections::HashSet::new();
951                for e in 0..bg.graph.ecount() {
952                    let (u, v) = bg.graph.edge(e as u32).unwrap();
953                    prop_assert!(u != v);
954                    prop_assert!(bg.types[u as usize] != bg.types[v as usize]);
955                    let key = if directed { (u, v) } else if u <= v { (u, v) } else { (v, u) };
956                    prop_assert!(seen.insert(key));
957                }
958            }
959
960            #[test]
961            fn gnm_exact_count_and_bipartite(
962                n1 in 1u32..10,
963                n2 in 1u32..10,
964                seed in any::<u64>(),
965            ) {
966                let cap = max_edges(n1, n2, false, BipartiteMode::All);
967                if cap == 0 { return Ok(()); }
968                let m = (seed as u64) % (cap + 1);
969                let bg = bipartite_game_gnm(n1, n2, m, false, BipartiteMode::All, seed).unwrap();
970                prop_assert_eq!(bg.graph.ecount() as u64, m);
971                let mut seen: std::collections::HashSet<(u32, u32)> =
972                    std::collections::HashSet::new();
973                for e in 0..bg.graph.ecount() {
974                    let (u, v) = bg.graph.edge(e as u32).unwrap();
975                    prop_assert!(u != v);
976                    prop_assert!(bg.types[u as usize] != bg.types[v as usize]);
977                    let key = if u <= v { (u, v) } else { (v, u) };
978                    prop_assert!(seen.insert(key));
979                }
980            }
981
982            #[test]
983            fn gnp_determinism(
984                n1 in 1u32..10,
985                n2 in 1u32..10,
986                p in 0.05..0.95,
987                directed in any::<bool>(),
988                mode in arb_mode(),
989                seed in any::<u64>(),
990            ) {
991                let a = bipartite_game_gnp(n1, n2, p, directed, mode, seed).unwrap();
992                let b = bipartite_game_gnp(n1, n2, p, directed, mode, seed).unwrap();
993                prop_assert_eq!(a.graph.ecount(), b.graph.ecount());
994                for e in 0..a.graph.ecount() {
995                    prop_assert_eq!(
996                        a.graph.edge(e as u32).unwrap(),
997                        b.graph.edge(e as u32).unwrap()
998                    );
999                }
1000            }
1001        }
1002    }
1003}