Skip to main content

rust_igraph/algorithms/games/
edge_switching_simple.rs

1//! Edge-switching MCMC degree-sequence simple-graph sampler
2//! (ALGO-GN-028).
3//!
4//! Counterpart of the `IGRAPH_DEGSEQ_EDGE_SWITCHING_SIMPLE` branch of
5//! `igraph_degree_sequence_game()` in
6//! `references/igraph/src/games/degree_sequence.c`
7//! (`edge_switching` lines 712-722). The reference dispatches to
8//! `igraph_realize_degree_sequence(..., IGRAPH_SIMPLE_SW,
9//! IGRAPH_REALIZE_DEGSEQ_INDEX)` for the deterministic seed, then runs
10//! `igraph_rewire(graph, 10 * ecount, IGRAPH_SIMPLE_SW, NULL)` for the
11//! MCMC mixing phase. We inline both phases to keep this AWU
12//! self-contained.
13//!
14//! ## Phase 1 — deterministic seed via Havel–Hakimi (undirected) /
15//! Kleitman–Wang (directed), INDEX order
16//!
17//! * **Undirected**: maintain `residual[v] = d_v` (mutable copy). For
18//!   each `i ∈ [0, n)`, treat vertex `i` as the hub with residual
19//!   degree `r = residual[i]`. Collect the `r` other vertices with the
20//!   highest current residual degrees (skipping `i`). Add edges
21//!   `(i, spoke)` and decrement each spoke's residual. Set
22//!   `residual[i] = 0`. If at any step fewer than `r` valid spokes
23//!   exist the sequence is non-graphical (rejected up front by the
24//!   shared Erdős–Gallai pre-check, so this is only a guard).
25//! * **Directed**: same loop over vertices in index order. At step
26//!   `i`, sort the remaining vertices by `(in-residual, out-residual)`
27//!   descending and connect vertex `i`'s `out_residual[i]` out-stubs
28//!   to the top-`r` distinct vertices (skipping `i` itself),
29//!   decrementing their in-residuals. Mirrors `igraph_i_kleitman_wang_index`.
30//!
31//! These seed builders are RNG-free. The Erdős–Gallai
32//! / Fulkerson–Chen–Anstee pre-checks (shared via `pub(crate)` helpers
33//! in [`crate::algorithms::games::degree_sequence_fast_heur`]) guarantee
34//! they always succeed when called from this entry point.
35//!
36//! ## Phase 2 — degree-preserving edge-switching MCMC
37//!
38//! The mixing kernel runs `10 · |E|` trials (matches the upstream
39//! constant). Each trial:
40//!
41//! 1. Sample `e1 ← RNG(0, |E|-1)`, then `e2 ← RNG(0, |E|-1)` until
42//!    `e1 ≠ e2`.
43//! 2. Let `(a,b)` = edge `e1`, `(c,d)` = edge `e2`.
44//! 3. **Undirected only**: with probability 0.5, swap `c` and `d`. This
45//!    is what lets the MCMC consider both rewiring orientations from
46//!    one pair of edges.
47//! 4. Reject if `a == c` or `b == d` (the swap would be a no-op).
48//! 5. Reject if `a == d` or `b == c` (the swap would create a
49//!    self-loop, and the simple-graph constraint forbids self-loops).
50//! 6. Reject if the rewired endpoints `(a,d)` or `(c,b)` already form
51//!    an edge in the current graph (would create a multi-edge).
52//! 7. Otherwise apply the swap: replace `(a,b),(c,d)` with
53//!    `(a,d),(c,b)` in both the edge list and the adjacency tracker.
54//!
55//! **Both successful and failed trials count toward the `10 · |E|`
56//! budget.** This is necessary for the chain to be detailed-balanced;
57//! conditioning on success would bias the stationary distribution.
58//!
59//! ## vs. siblings
60//!
61//! * [`crate::degree_sequence_game_configuration`] (ALGO-GN-024) — no
62//!   simplicity guarantee; produces a multigraph.
63//! * [`crate::degree_sequence_game_configuration_simple`] (ALGO-GN-027)
64//!   — exact-uniform rejection sampler on the simple-graph space; cost
65//!   grows as `exp(O((Σd/n)²))`. This MCMC variant trades exact
66//!   uniformity for `O(|E|)` runtime independent of density.
67//! * [`crate::degree_sequence_game_fast_heur_simple`] (ALGO-GN-026) —
68//!   biased single-pass heuristic; fastest but not asymptotically
69//!   uniform.
70//! * [`crate::degree_sequence_game_vl`] (ALGO-GN-025) — Viger–Latapy
71//!   MCMC on **simple connected** graphs; trades the connectivity
72//!   guarantee for tighter mixing bounds.
73//!
74//! ## Determinism
75//!
76//! A single `SplitMix64` seed drives both the (RNG-free) seed phase
77//! and every rewire trial. The PRNG is not bitwise portable to igraph C
78//! / `NumPy` / R, so the three-source conformance harness asserts
79//! structural invariants only (vcount, ecount, exact degree match,
80//! simplicity).
81//!
82//! ## Failure modes
83//!
84//! Non-graphical input is rejected up front by Erdős–Gallai (undirected)
85//! or Fulkerson–Chen–Anstee (directed). The seed builder and MCMC kernel
86//! never abort after that point: the sampler is best-effort and returns
87//! whatever simple graph the chain has reached after `10 · |E|` trials,
88//! exactly mirroring upstream semantics.
89
90#![allow(
91    unknown_lints,
92    clippy::cast_possible_truncation,
93    clippy::cast_sign_loss,
94    clippy::many_single_char_names,
95    clippy::needless_range_loop
96)]
97
98use std::collections::HashSet;
99
100use crate::algorithms::games::degree_sequence_fast_heur::{
101    checked_sum, is_graphical_simple_directed, is_graphical_simple_undirected,
102};
103use crate::core::rng::SplitMix64;
104use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
105
106/// Upstream constant: number of rewire trials per edge. The C reference
107/// hard-codes `10 * igraph_ecount(graph)` in `edge_switching()` at
108/// `references/igraph/src/games/degree_sequence.c:719`.
109pub const REWIRE_TRIALS_PER_EDGE: u64 = 10;
110
111/// Sample uniformly from `[low, high]` inclusive. Mirrors the
112/// `RNG_INTEGER(low, high)` semantics of the C reference.
113fn rng_integer_inclusive(rng: &mut SplitMix64, low: usize, high: usize) -> usize {
114    debug_assert!(low <= high, "rng_integer_inclusive: low ≤ high");
115    let span = (high - low) as u64 + 1;
116    low + (rng.next_u64() % span) as usize
117}
118
119/// One unbiased coin flip. Matches `RNG_BOOL()` in the C reference.
120fn rng_bool(rng: &mut SplitMix64) -> bool {
121    (rng.next_u64() & 1) == 1
122}
123
124// ---------------------------------------------------------------- seed: undirected
125
126fn build_seed_undirected(degrees: &[u32]) -> IgraphResult<Vec<(VertexId, VertexId)>> {
127    let n = degrees.len();
128    let total_u64 = checked_sum(degrees)?;
129    if total_u64 % 2 != 0 {
130        return Err(IgraphError::InvalidArgument(
131            "degree_sequence_game_edge_switching_simple: undirected degree sum must be even"
132                .to_string(),
133        ));
134    }
135    let ecount = (total_u64 / 2) as usize;
136    if ecount == 0 {
137        return Ok(Vec::new());
138    }
139
140    let mut residual: Vec<i64> = degrees.iter().map(|&d| i64::from(d)).collect();
141    let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(ecount);
142    // Reused scratch buffer for candidate spokes.
143    let mut order: Vec<u32> = Vec::with_capacity(n);
144
145    for i in 0..n {
146        let r = residual[i];
147        if r <= 0 {
148            continue;
149        }
150        // Hub is fully used after this step; mark it now so it can't be
151        // accidentally picked as a spoke.
152        residual[i] = 0;
153
154        // Collect all vertices with positive residual, then take the
155        // top-r by residual (descending), ties by index ascending.
156        order.clear();
157        for j in 0..n {
158            if j == i {
159                continue;
160            }
161            if residual[j] > 0 {
162                order.push(u32::try_from(j).map_err(|_| {
163                    IgraphError::Internal("vertex index exceeds u32 (seed builder)")
164                })?);
165            }
166        }
167        let r_usize = usize::try_from(r).map_err(|_| {
168            IgraphError::Internal(
169                "residual degree negative in Havel–Hakimi (should be unreachable)",
170            )
171        })?;
172        if order.len() < r_usize {
173            // Should be impossible given the EG pre-check, but the
174            // upstream library raises EINVAL here.
175            return Err(IgraphError::InvalidArgument(
176                "degree_sequence_game_edge_switching_simple: degree sequence is not simply realisable (Havel–Hakimi)"
177                    .to_string(),
178            ));
179        }
180        // Sort descending by residual; ties broken by vertex index
181        // ascending (stable, deterministic).
182        order.sort_by(|&a, &b| {
183            residual[b as usize]
184                .cmp(&residual[a as usize])
185                .then(a.cmp(&b))
186        });
187
188        for &spoke in &order[..r_usize] {
189            edges.push((
190                u32::try_from(i)
191                    .map_err(|_| IgraphError::Internal("vertex index exceeds u32 (seed hub)"))?,
192                spoke,
193            ));
194            residual[spoke as usize] -= 1;
195        }
196    }
197
198    Ok(edges)
199}
200
201// ---------------------------------------------------------------- seed: directed
202
203fn build_seed_directed(
204    out_degrees: &[u32],
205    in_degrees: &[u32],
206) -> IgraphResult<Vec<(VertexId, VertexId)>> {
207    let n = out_degrees.len();
208    let out_total = checked_sum(out_degrees)? as usize;
209    let in_total = checked_sum(in_degrees)? as usize;
210    if out_total != in_total {
211        return Err(IgraphError::InvalidArgument(
212            "degree_sequence_game_edge_switching_simple: directed sums Σout and Σin must match"
213                .to_string(),
214        ));
215    }
216    if out_total == 0 {
217        return Ok(Vec::new());
218    }
219
220    let mut out_residual: Vec<i64> = out_degrees.iter().map(|&d| i64::from(d)).collect();
221    let mut in_residual: Vec<i64> = in_degrees.iter().map(|&d| i64::from(d)).collect();
222    let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(out_total);
223    let mut order: Vec<u32> = Vec::with_capacity(n);
224
225    for i in 0..n {
226        let r = out_residual[i];
227        if r <= 0 {
228            continue;
229        }
230        out_residual[i] = 0;
231
232        order.clear();
233        for j in 0..n {
234            if j == i {
235                continue;
236            }
237            if in_residual[j] > 0 {
238                order.push(u32::try_from(j).map_err(|_| {
239                    IgraphError::Internal("vertex index exceeds u32 (directed seed)")
240                })?);
241            }
242        }
243        let r_usize = usize::try_from(r).map_err(|_| {
244            IgraphError::Internal("out residual negative in Kleitman–Wang (should be unreachable)")
245        })?;
246        if order.len() < r_usize {
247            return Err(IgraphError::InvalidArgument(
248                "degree_sequence_game_edge_switching_simple: directed degree pair is not simply realisable (Kleitman–Wang)"
249                    .to_string(),
250            ));
251        }
252        // Mirrors `degree_greater<vbd_pair>` ordering used by the
253        // upstream Kleitman–Wang INDEX implementation: descending by
254        // (in_residual, out_residual). Ties broken by vertex index
255        // ascending for deterministic output.
256        order.sort_by(|&a, &b| {
257            in_residual[b as usize]
258                .cmp(&in_residual[a as usize])
259                .then(out_residual[b as usize].cmp(&out_residual[a as usize]))
260                .then(a.cmp(&b))
261        });
262
263        for &target in &order[..r_usize] {
264            edges.push((
265                u32::try_from(i).map_err(|_| {
266                    IgraphError::Internal("vertex index exceeds u32 (directed hub)")
267                })?,
268                target,
269            ));
270            in_residual[target as usize] -= 1;
271        }
272    }
273
274    Ok(edges)
275}
276
277// ---------------------------------------------------------------- rewire phase
278
279fn rewire_undirected(
280    n_vertices: u32,
281    edges: &mut [(VertexId, VertexId)],
282    rng: &mut SplitMix64,
283    trials: u64,
284) {
285    let m = edges.len();
286    if m < 2 {
287        return;
288    }
289    let vcount = n_vertices as usize;
290    let mut adj: Vec<HashSet<u32>> = (0..vcount).map(|_| HashSet::new()).collect();
291    for &(u, v) in edges.iter() {
292        adj[u as usize].insert(v);
293        adj[v as usize].insert(u);
294    }
295
296    let mut trial: u64 = 0;
297    while trial < trials {
298        trial += 1;
299
300        let e1 = rng_integer_inclusive(rng, 0, m - 1);
301        let mut e2;
302        loop {
303            e2 = rng_integer_inclusive(rng, 0, m - 1);
304            if e2 != e1 {
305                break;
306            }
307        }
308
309        let (a, b) = edges[e1];
310        let (mut c, mut d) = edges[e2];
311
312        // Undirected: with prob 0.5, consider the swapped orientation
313        // of the second edge. The rewriting then becomes
314        // (a,b),(d,c) → (a,c),(d,b), which covers the second of the
315        // two valid degree-preserving rewirings.
316        if rng_bool(rng) {
317            std::mem::swap(&mut c, &mut d);
318        }
319
320        // Reject no-ops and self-loops.
321        if a == c || b == d {
322            continue;
323        }
324        if a == d || b == c {
325            continue;
326        }
327        // Reject if the rewired edges would collide with existing ones.
328        if adj[a as usize].contains(&d) {
329            continue;
330        }
331        if adj[c as usize].contains(&b) {
332            continue;
333        }
334
335        // Apply: (a,b),(c,d) → (a,d),(c,b).
336        // Adjacency: drop b from a, drop a from b, drop d from c, drop
337        // c from d; add d to a, a to d, b to c, c to b.
338        adj[a as usize].remove(&b);
339        adj[b as usize].remove(&a);
340        adj[c as usize].remove(&d);
341        adj[d as usize].remove(&c);
342        adj[a as usize].insert(d);
343        adj[d as usize].insert(a);
344        adj[c as usize].insert(b);
345        adj[b as usize].insert(c);
346
347        // Preserve the same internal orientation as the original edge so
348        // the edge-list direction stays stable across trials (cosmetic;
349        // both orientations represent the same undirected edge).
350        edges[e1] = (a, d);
351        edges[e2] = (c, b);
352    }
353}
354
355fn rewire_directed(
356    n_vertices: u32,
357    edges: &mut [(VertexId, VertexId)],
358    rng: &mut SplitMix64,
359    trials: u64,
360) {
361    let m = edges.len();
362    if m < 2 {
363        return;
364    }
365    let vcount = n_vertices as usize;
366    // For directed graphs the multi-arc check is on out-adjacency:
367    // (a,d) is a multi-arc only if a→d already exists.
368    let mut out_adj: Vec<HashSet<u32>> = (0..vcount).map(|_| HashSet::new()).collect();
369    for &(u, v) in edges.iter() {
370        out_adj[u as usize].insert(v);
371    }
372
373    let mut trial: u64 = 0;
374    while trial < trials {
375        trial += 1;
376
377        let e1 = rng_integer_inclusive(rng, 0, m - 1);
378        let mut e2;
379        loop {
380            e2 = rng_integer_inclusive(rng, 0, m - 1);
381            if e2 != e1 {
382                break;
383            }
384        }
385
386        let (a, b) = edges[e1];
387        let (c, d) = edges[e2];
388
389        // Directed: there is only one valid degree-preserving rewiring
390        // of two arcs (a→b),(c→d) — namely (a→d),(c→b). No coin flip.
391        if a == c || b == d {
392            continue; // no-op
393        }
394        if a == d || b == c {
395            continue; // would create a self-loop
396        }
397        if out_adj[a as usize].contains(&d) {
398            continue; // multi-arc
399        }
400        if out_adj[c as usize].contains(&b) {
401            continue; // multi-arc
402        }
403
404        out_adj[a as usize].remove(&b);
405        out_adj[c as usize].remove(&d);
406        out_adj[a as usize].insert(d);
407        out_adj[c as usize].insert(b);
408
409        edges[e1] = (a, d);
410        edges[e2] = (c, b);
411    }
412}
413
414// ---------------------------------------------------------------- public API
415
416/// Sample a random simple graph realising the given degree sequence via
417/// degree-preserving edge-switching MCMC (ALGO-GN-028).
418///
419/// Mirrors `IGRAPH_DEGSEQ_EDGE_SWITCHING_SIMPLE`. The algorithm builds
420/// a deterministic Havel–Hakimi (undirected) or Kleitman–Wang
421/// (directed) seed graph by INDEX order, then runs `10 · |E|`
422/// degree-preserving edge swaps (Markov-chain Monte Carlo). Both
423/// successful and failed swap trials count toward the budget, which is
424/// what makes the chain detailed-balanced.
425///
426/// The output is guaranteed to be a simple graph (no self-loops, no
427/// multi-edges / multi-arcs) realising the prescribed sequence
428/// exactly. The output distribution converges to the uniform
429/// distribution on simple realisations as the number of trials grows
430/// — the upstream `10 · |E|` constant is a pragmatic compromise
431/// between mixing quality and wall-clock cost, not a tight bound.
432///
433/// # Arguments
434///
435/// * `out_degrees` — undirected mode: the degree of each vertex.
436///   Directed mode: the out-degree of each vertex.
437/// * `in_degrees`:
438///   * `None` → undirected. Requires `Σ out_degrees` even and the
439///     sequence to satisfy Erdős–Gallai.
440///   * `Some(in_seq)` → directed. Requires equal lengths, equal sums,
441///     and the pair to satisfy Fulkerson–Chen–Anstee.
442/// * `seed` — drives a `SplitMix64` PRNG. The same
443///   `(out_degrees, in_degrees, seed)` triple always produces the same
444///   graph.
445///
446/// # Errors
447///
448/// Returns `IgraphError::InvalidArgument` if the sequence is
449/// non-graphical (rejected by Erdős–Gallai or Fulkerson–Chen–Anstee).
450/// The MCMC phase itself cannot fail.
451///
452/// # Examples
453///
454/// ```
455/// use rust_igraph::degree_sequence_game_edge_switching_simple;
456///
457/// // 4-cycle: every vertex has degree 2.
458/// let g = degree_sequence_game_edge_switching_simple(&[2, 2, 2, 2], None, 17).unwrap();
459/// assert_eq!(g.vcount(), 4);
460/// assert_eq!(g.ecount(), 4);
461/// assert!(!g.is_directed());
462/// ```
463pub fn degree_sequence_game_edge_switching_simple(
464    out_degrees: &[u32],
465    in_degrees: Option<&[u32]>,
466    seed: u64,
467) -> IgraphResult<Graph> {
468    let directed = in_degrees.is_some();
469    let n = u32::try_from(out_degrees.len())
470        .map_err(|_| IgraphError::Internal("vertex count exceeds u32"))?;
471
472    if let Some(in_seq) = in_degrees {
473        if in_seq.len() != out_degrees.len() {
474            return Err(IgraphError::InvalidArgument(
475                "degree_sequence_game_edge_switching_simple: out_degrees and in_degrees must have the same length"
476                    .to_string(),
477            ));
478        }
479    }
480
481    if directed {
482        let Some(in_seq) = in_degrees else {
483            return Err(IgraphError::InvalidArgument(
484                "directed graph requires in_degrees".to_string(),
485            ));
486        };
487        if !is_graphical_simple_directed(out_degrees, in_seq) {
488            return Err(IgraphError::InvalidArgument(
489                "degree_sequence_game_edge_switching_simple: degree pair is not realisable as a simple directed graph (Fulkerson–Chen–Anstee)"
490                    .to_string(),
491            ));
492        }
493    } else if !is_graphical_simple_undirected(out_degrees) {
494        return Err(IgraphError::InvalidArgument(
495            "degree_sequence_game_edge_switching_simple: degree sequence is not realisable as a simple undirected graph (Erdős–Gallai)"
496                .to_string(),
497        ));
498    }
499
500    let mut rng = SplitMix64::new(seed);
501
502    let mut edges = if directed {
503        let Some(in_seq) = in_degrees else {
504            return Err(IgraphError::InvalidArgument(
505                "directed graph requires in_degrees".to_string(),
506            ));
507        };
508        build_seed_directed(out_degrees, in_seq)?
509    } else {
510        build_seed_undirected(out_degrees)?
511    };
512
513    let trials = REWIRE_TRIALS_PER_EDGE
514        .checked_mul(edges.len() as u64)
515        .ok_or(IgraphError::Internal(
516            "rewire trial count overflowed u64 (edge count too large)",
517        ))?;
518
519    if directed {
520        rewire_directed(n, &mut edges, &mut rng, trials);
521    } else {
522        rewire_undirected(n, &mut edges, &mut rng, trials);
523    }
524
525    let mut g = Graph::new(n, directed)?;
526    g.add_edges(edges)?;
527    Ok(g)
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use crate::algorithms::properties::{SimpleMode, is_simple_with_mode};
534
535    fn observed_degrees(g: &Graph) -> Vec<u32> {
536        let n = g.vcount() as usize;
537        let mut deg = vec![0u32; n];
538        let ec = u32::try_from(g.ecount()).expect("ecount fits u32");
539        for eid in 0..ec {
540            let (s, t) = g.edge(eid).expect("eid in bounds");
541            deg[s as usize] = deg[s as usize].saturating_add(1);
542            deg[t as usize] = deg[t as usize].saturating_add(1);
543        }
544        deg
545    }
546
547    fn observed_out_in(g: &Graph) -> (Vec<u32>, Vec<u32>) {
548        let n = g.vcount() as usize;
549        let mut out = vec![0u32; n];
550        let mut inv = vec![0u32; n];
551        let ec = u32::try_from(g.ecount()).expect("ecount fits u32");
552        for eid in 0..ec {
553            let (s, t) = g.edge(eid).expect("eid in bounds");
554            out[s as usize] = out[s as usize].saturating_add(1);
555            inv[t as usize] = inv[t as usize].saturating_add(1);
556        }
557        (out, inv)
558    }
559
560    #[test]
561    fn undirected_empty_sequence_yields_empty_graph() {
562        let g = degree_sequence_game_edge_switching_simple(&[], None, 1).expect("empty ok");
563        assert_eq!(g.vcount(), 0);
564        assert_eq!(g.ecount(), 0);
565        assert!(!g.is_directed());
566    }
567
568    #[test]
569    fn undirected_singleton_zero_yields_isolated_vertex() {
570        let g = degree_sequence_game_edge_switching_simple(&[0], None, 1).expect("singleton ok");
571        assert_eq!(g.vcount(), 1);
572        assert_eq!(g.ecount(), 0);
573    }
574
575    #[test]
576    fn undirected_all_isolated_n5_yields_no_edges() {
577        let g = degree_sequence_game_edge_switching_simple(&[0; 5], None, 42).expect("ok");
578        assert_eq!(g.vcount(), 5);
579        assert_eq!(g.ecount(), 0);
580    }
581
582    #[test]
583    fn undirected_4cycle_preserves_degrees_and_is_simple() {
584        let g = degree_sequence_game_edge_switching_simple(&[2, 2, 2, 2], None, 7).expect("ok");
585        assert_eq!(g.vcount(), 4);
586        assert_eq!(g.ecount(), 4);
587        assert_eq!(observed_degrees(&g), vec![2, 2, 2, 2]);
588        assert!(is_simple_with_mode(&g, SimpleMode::DirectedAsDirected).unwrap());
589    }
590
591    #[test]
592    fn undirected_3regular_n6_preserves_degrees() {
593        let degrees: Vec<u32> = vec![3; 6];
594        let g = degree_sequence_game_edge_switching_simple(&degrees, None, 0xABCD_u64).expect("ok");
595        assert_eq!(observed_degrees(&g), degrees);
596        assert!(is_simple_with_mode(&g, SimpleMode::DirectedAsDirected).unwrap());
597    }
598
599    #[test]
600    fn undirected_skewed_powerlaw_preserves_degrees() {
601        let degrees: Vec<u32> = vec![5, 4, 4, 3, 3, 3, 2, 2, 2, 2];
602        let g = degree_sequence_game_edge_switching_simple(&degrees, None, 0xC0FE_u64).expect("ok");
603        assert_eq!(observed_degrees(&g), degrees);
604        assert!(is_simple_with_mode(&g, SimpleMode::DirectedAsDirected).unwrap());
605    }
606
607    #[test]
608    fn undirected_3regular_n30_preserves_degrees() {
609        let degrees: Vec<u32> = vec![3; 30];
610        let g = degree_sequence_game_edge_switching_simple(&degrees, None, 0xDEAD_F00D_u64)
611            .expect("ok");
612        assert_eq!(observed_degrees(&g), degrees);
613        assert!(is_simple_with_mode(&g, SimpleMode::DirectedAsDirected).unwrap());
614    }
615
616    /// Dense regime that the `configuration_simple` rejection sampler
617    /// (ALGO-GN-027) can struggle with. The MCMC variant should handle
618    /// it cleanly because cost is linear in `|E|`.
619    #[test]
620    fn undirected_dense_5regular_n20_preserves_degrees() {
621        let degrees: Vec<u32> = vec![5; 20];
622        let g = degree_sequence_game_edge_switching_simple(&degrees, None, 9001).expect("ok");
623        assert_eq!(observed_degrees(&g), degrees);
624        assert!(is_simple_with_mode(&g, SimpleMode::DirectedAsDirected).unwrap());
625    }
626
627    #[test]
628    fn undirected_odd_sum_rejected() {
629        let err = degree_sequence_game_edge_switching_simple(&[1, 1, 1], None, 1).unwrap_err();
630        matches!(err, IgraphError::InvalidArgument(_));
631    }
632
633    #[test]
634    fn undirected_non_graphical_rejected_max_too_large() {
635        let err = degree_sequence_game_edge_switching_simple(&[5, 3, 1, 1], None, 1).unwrap_err();
636        matches!(err, IgraphError::InvalidArgument(_));
637    }
638
639    #[test]
640    fn deterministic_same_seed_undirected() {
641        let degrees: Vec<u32> = vec![3; 8];
642        let g1 = degree_sequence_game_edge_switching_simple(&degrees, None, 4242).expect("ok");
643        let g2 = degree_sequence_game_edge_switching_simple(&degrees, None, 4242).expect("ok");
644        let mut e1: Vec<(u32, u32)> = (0..u32::try_from(g1.ecount()).unwrap())
645            .map(|i| {
646                let (a, b) = g1.edge(i).unwrap();
647                if a < b { (a, b) } else { (b, a) }
648            })
649            .collect();
650        let mut e2: Vec<(u32, u32)> = (0..u32::try_from(g2.ecount()).unwrap())
651            .map(|i| {
652                let (a, b) = g2.edge(i).unwrap();
653                if a < b { (a, b) } else { (b, a) }
654            })
655            .collect();
656        e1.sort_unstable();
657        e2.sort_unstable();
658        assert_eq!(e1, e2);
659    }
660
661    #[test]
662    fn directed_empty_sequence_yields_empty_graph() {
663        let g = degree_sequence_game_edge_switching_simple(&[], Some(&[]), 1).expect("ok");
664        assert_eq!(g.vcount(), 0);
665        assert_eq!(g.ecount(), 0);
666        assert!(g.is_directed());
667    }
668
669    #[test]
670    fn directed_2cycle_preserves_in_out() {
671        let g = degree_sequence_game_edge_switching_simple(&[1, 1], Some(&[1, 1]), 9).expect("ok");
672        let (out, inv) = observed_out_in(&g);
673        assert_eq!(out, vec![1, 1]);
674        assert_eq!(inv, vec![1, 1]);
675        assert!(is_simple_with_mode(&g, SimpleMode::DirectedAsDirected).unwrap());
676    }
677
678    #[test]
679    fn directed_balanced_n6_d2_preserves_degrees() {
680        let n = 6;
681        let out = vec![2u32; n];
682        let inv = vec![2u32; n];
683        let g =
684            degree_sequence_game_edge_switching_simple(&out, Some(&inv), 0xC0DE_u64).expect("ok");
685        let (got_out, got_in) = observed_out_in(&g);
686        assert_eq!(got_out, out);
687        assert_eq!(got_in, inv);
688        assert!(is_simple_with_mode(&g, SimpleMode::DirectedAsDirected).unwrap());
689    }
690
691    #[test]
692    fn directed_unequal_sums_rejected() {
693        let err = degree_sequence_game_edge_switching_simple(&[1, 1, 1], Some(&[1, 1, 0]), 1)
694            .unwrap_err();
695        matches!(err, IgraphError::InvalidArgument(_));
696    }
697
698    #[test]
699    fn directed_length_mismatch_rejected() {
700        let err =
701            degree_sequence_game_edge_switching_simple(&[1, 1], Some(&[1, 1, 0]), 1).unwrap_err();
702        matches!(err, IgraphError::InvalidArgument(_));
703    }
704
705    #[test]
706    fn directed_deterministic_same_seed() {
707        let out = vec![2u32; 5];
708        let inv = vec![2u32; 5];
709        let g1 = degree_sequence_game_edge_switching_simple(&out, Some(&inv), 12345).expect("ok");
710        let g2 = degree_sequence_game_edge_switching_simple(&out, Some(&inv), 12345).expect("ok");
711        let e1: Vec<(u32, u32)> = (0..u32::try_from(g1.ecount()).unwrap())
712            .map(|i| g1.edge(i).unwrap())
713            .collect();
714        let e2: Vec<(u32, u32)> = (0..u32::try_from(g2.ecount()).unwrap())
715            .map(|i| g2.edge(i).unwrap())
716            .collect();
717        assert_eq!(e1, e2);
718    }
719}
720
721#[cfg(all(test, feature = "proptest-harness"))]
722mod proptest_invariants {
723    use super::*;
724    use proptest::prelude::*;
725
726    /// Generate graphical undirected degree sequences. We allow a
727    /// wider density envelope than the GN-027 strategy because this
728    /// sampler's per-call cost is linear in `|E|` regardless of
729    /// density (no rejection restarts).
730    fn graphical_undirected_strategy() -> impl Strategy<Value = Vec<u32>> {
731        (4usize..=12).prop_flat_map(|n| {
732            let cap = (n as u32).saturating_sub(1);
733            prop::collection::vec(0u32..=cap, n).prop_filter(
734                "must be graphical (even sum + EG)",
735                move |seq| {
736                    let sum: u64 = seq.iter().map(|&d| u64::from(d)).sum();
737                    sum % 2 == 0 && is_graphical_simple_undirected(seq)
738                },
739            )
740        })
741    }
742
743    proptest! {
744        #[test]
745        fn degrees_preserved_undirected(
746            seq in graphical_undirected_strategy(),
747            seed in any::<u64>(),
748        ) {
749            let g = degree_sequence_game_edge_switching_simple(&seq, None, seed)
750                .expect("graphical sequence must succeed");
751            let n = g.vcount() as usize;
752            let mut deg = vec![0u32; n];
753            let ec = u32::try_from(g.ecount()).unwrap();
754            for eid in 0..ec {
755                let (s, t) = g.edge(eid).unwrap();
756                deg[s as usize] += 1;
757                deg[t as usize] += 1;
758            }
759            prop_assert_eq!(deg, seq);
760        }
761
762        #[test]
763        fn simple_no_loops_no_multi_undirected(
764            seq in graphical_undirected_strategy(),
765            seed in any::<u64>(),
766        ) {
767            use crate::algorithms::properties::{SimpleMode, is_simple_with_mode};
768            let g = degree_sequence_game_edge_switching_simple(&seq, None, seed)
769                .expect("graphical sequence must succeed");
770            prop_assert!(is_simple_with_mode(&g, SimpleMode::DirectedAsDirected).unwrap());
771        }
772
773        #[test]
774        fn same_seed_same_graph(
775            seq in graphical_undirected_strategy(),
776            seed in any::<u64>(),
777        ) {
778            let g1 = degree_sequence_game_edge_switching_simple(&seq, None, seed).unwrap();
779            let g2 = degree_sequence_game_edge_switching_simple(&seq, None, seed).unwrap();
780            let e1: Vec<(u32, u32)> = (0..u32::try_from(g1.ecount()).unwrap())
781                .map(|i| g1.edge(i).unwrap())
782                .collect();
783            let e2: Vec<(u32, u32)> = (0..u32::try_from(g2.ecount()).unwrap())
784                .map(|i| g2.edge(i).unwrap())
785                .collect();
786            prop_assert_eq!(e1, e2);
787        }
788    }
789}