Skip to main content

rust_igraph/algorithms/games/
degree_sequence_fast_heur.rs

1//! Fast-heuristic simple-graph degree-sequence generator (ALGO-GN-026).
2//!
3//! Counterpart of the `IGRAPH_DEGSEQ_FAST_HEUR_SIMPLE` branch of
4//! `igraph_degree_sequence_game()` in
5//! `references/igraph/src/games/degree_sequence.c`
6//! (`fast_heur_undirected` lines 125-261, `fast_heur_directed`
7//! lines 263-402).
8//!
9//! The routine samples a **simple** graph (no self-loops, no multi-edges)
10//! with a prescribed degree sequence using a one-pass stub-matching
11//! heuristic with single-cycle backtracking:
12//!
13//! 1. Build a stub bag from the residual degree sequence and shuffle it.
14//! 2. Walk pairs `(stubs[2i], stubs[2i+1])`; accept the edge if it would
15//!    not create a self-loop and the endpoint pair is not yet incident.
16//!    Otherwise bump residual degrees on both endpoints and mark them as
17//!    "incomplete".
18//! 3. If the incomplete set is empty, the attempt succeeded.
19//! 4. Else, scan the incomplete vertices for a feasible non-edge pair.
20//!    If at least one exists, repeat from (1) using only the residual
21//!    stubs. If none exists, restart the entire attempt from scratch.
22//!
23//! Unlike the configuration-model generator (ALGO-GN-024), the output is
24//! guaranteed to be simple. Unlike the Viger–Latapy sampler
25//! (ALGO-GN-025), the result is **not** uniform on the space of simple
26//! graphs realising the sequence: the bias of the heuristic is acceptable
27//! for many applications but is the price for a single-pass attempt with
28//! `O(Σd · log Σd)` complexity on the median attempt.
29//!
30//! ## Directed vs undirected
31//!
32//! * **Undirected** (`in_degrees = None`): one stub bag, paired as
33//!   `(stubs[2i], stubs[2i+1])` after a Fisher–Yates shuffle.
34//! * **Directed** (`in_degrees = Some(in_seq)`): two stub bags
35//!   (out + in). Only the out-bag is shuffled; the in-bag is consumed in
36//!   construction order, matching the C reference.
37//!
38//! ## Connectivity
39//!
40//! This generator does **not** enforce connectivity. For uniformly random
41//! *simple connected* samples use `degree_sequence_game_vl` (ALGO-GN-025).
42//!
43//! ## Determinism
44//!
45//! A single `SplitMix64` seed drives every shuffle. The PRNG is not
46//! bitwise portable to igraph C / `NumPy` / R, so the three-source
47//! conformance harness asserts structural invariants only (vcount,
48//! ecount = Σd/2, exact degree match, simplicity).
49//!
50//! ## Failure modes
51//!
52//! Non-graphical input is rejected up front. For sequences that pass the
53//! graphicality test but happen to be especially hostile to the heuristic
54//! (e.g. very dense, near-regular sequences with low slack), the
55//! attempt-restart counter is bounded by `MAX_OUTER_ATTEMPTS` (1024); the
56//! function returns `InvalidArgument` once exhausted.
57
58#![allow(
59    unknown_lints,
60    clippy::cast_possible_truncation,
61    clippy::cast_precision_loss,
62    clippy::cast_sign_loss,
63    clippy::many_single_char_names,
64    clippy::needless_range_loop,
65    clippy::manual_contains
66)]
67
68use std::collections::BTreeSet;
69
70use crate::core::rng::SplitMix64;
71use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
72
73/// Cap on outer attempt restarts before giving up.
74const MAX_OUTER_ATTEMPTS: u32 = 1024;
75
76/// Sum a `u32` slice into a `u64` with overflow checking.
77pub(crate) fn checked_sum(degrees: &[u32]) -> IgraphResult<u64> {
78    let mut acc: u64 = 0;
79    for &d in degrees {
80        acc = acc
81            .checked_add(u64::from(d))
82            .ok_or(IgraphError::Internal("degree-sum overflow"))?;
83    }
84    Ok(acc)
85}
86
87/// Erdős–Gallai test (undirected simple graph).
88pub(crate) fn is_graphical_simple_undirected(degrees: &[u32]) -> bool {
89    let n = degrees.len();
90    if n == 0 {
91        return true;
92    }
93    let sum: u64 = degrees.iter().map(|&d| u64::from(d)).sum();
94    if sum % 2 != 0 {
95        return false;
96    }
97    let n_u64 = n as u64;
98    if degrees.iter().any(|&d| u64::from(d) >= n_u64) {
99        return false;
100    }
101    let mut d_desc: Vec<u32> = degrees.to_vec();
102    d_desc.sort_by(|a, b| b.cmp(a));
103    let mut left_sum: u64 = 0;
104    for k in 1..=n {
105        left_sum = left_sum.saturating_add(u64::from(d_desc[k - 1]));
106        let k_u64 = k as u64;
107        let bound_right: u64 = d_desc[k..].iter().map(|&d| u64::from(d).min(k_u64)).sum();
108        let rhs = k_u64
109            .saturating_mul(k_u64.saturating_sub(1))
110            .saturating_add(bound_right);
111        if left_sum > rhs {
112            return false;
113        }
114    }
115    true
116}
117
118/// Fulkerson–Chen–Anstee test for directed simple graph (no self-loops).
119///
120/// A pair of sequences `(a_i, b_i)` with `Σa = Σb` is realisable as a
121/// simple directed graph iff, after re-ordering indices `π` so that
122/// `a_{π(1)} ≥ … ≥ a_{π(n)}`, for every `k = 1..n`,
123/// `Σ_{i≤k} a_{π(i)} ≤ Σ_{i≤k} min(b_{π(i)}, k − 1) + Σ_{i>k} min(b_{π(i)}, k)`.
124pub(crate) fn is_graphical_simple_directed(out_degrees: &[u32], in_degrees: &[u32]) -> bool {
125    let n = out_degrees.len();
126    if n != in_degrees.len() {
127        return false;
128    }
129    if n == 0 {
130        return true;
131    }
132    let out_sum: u64 = out_degrees.iter().map(|&d| u64::from(d)).sum();
133    let in_sum: u64 = in_degrees.iter().map(|&d| u64::from(d)).sum();
134    if out_sum != in_sum {
135        return false;
136    }
137    let n_u64 = n as u64;
138    if out_degrees.iter().any(|&d| u64::from(d) >= n_u64) {
139        return false;
140    }
141    if in_degrees.iter().any(|&d| u64::from(d) >= n_u64) {
142        return false;
143    }
144    // Sort indices by out-degree descending; break ties by larger in-degree
145    // (Fulkerson–Chen–Anstee uses this canonical "corrected" permutation).
146    let mut idx: Vec<usize> = (0..n).collect();
147    idx.sort_by(|&i, &j| {
148        out_degrees[j]
149            .cmp(&out_degrees[i])
150            .then_with(|| in_degrees[j].cmp(&in_degrees[i]))
151    });
152    let a: Vec<u32> = idx.iter().map(|&i| out_degrees[i]).collect();
153    let b: Vec<u32> = idx.iter().map(|&i| in_degrees[i]).collect();
154    let mut left_sum: u64 = 0;
155    for k in 1..=n {
156        left_sum = left_sum.saturating_add(u64::from(a[k - 1]));
157        let k_u64 = k as u64;
158        // Σ_{i ≤ k} min(b_i, k-1) + Σ_{i > k} min(b_i, k)
159        let cap_left: u64 = (0..k)
160            .map(|i| u64::from(b[i]).min(k_u64.saturating_sub(1)))
161            .sum();
162        let cap_right: u64 = (k..n).map(|i| u64::from(b[i]).min(k_u64)).sum();
163        let rhs = cap_left.saturating_add(cap_right);
164        if left_sum > rhs {
165            return false;
166        }
167    }
168    true
169}
170
171/// Fisher–Yates shuffle in place using the supplied PRNG (range
172/// `[0, bound)` from a 64-bit draw, biased-but-uniform-enough for our
173/// stub-shuffle needs).
174fn fisher_yates<T>(slice: &mut [T], rng: &mut SplitMix64) {
175    let n = slice.len();
176    if n < 2 {
177        return;
178    }
179    for i in (1..n).rev() {
180        let bound = (i + 1) as u64;
181        let j = (rng.next_u64() % bound) as usize;
182        slice.swap(i, j);
183    }
184}
185
186/// Per-vertex adjacency stored as a sorted `Vec<u32>` of neighbours. Edge
187/// membership tests are `O(log d)` via binary search; insertions preserve
188/// the sort order. We index the same way the C reference does so that the
189/// undirected/directed branches share code paths.
190type Adj = Vec<Vec<u32>>;
191
192fn adj_contains(neis: &[u32], target: u32) -> bool {
193    neis.binary_search(&target).is_ok()
194}
195
196fn adj_insert(neis: &mut Vec<u32>, target: u32) {
197    match neis.binary_search(&target) {
198        Ok(_) => {} // already present (shouldn't happen if caller checked)
199        Err(pos) => neis.insert(pos, target),
200    }
201}
202
203/// Run the undirected fast-heuristic loop. On success returns the edge
204/// list with `src ≤ dst` canonicalisation.
205fn run_undirected(
206    degrees: &[u32],
207    n: u32,
208    rng: &mut SplitMix64,
209) -> IgraphResult<Vec<(VertexId, VertexId)>> {
210    let nu = n as usize;
211    let mut residual: Vec<u32> = degrees.to_vec();
212    let mut adj: Adj = vec![Vec::new(); nu];
213    let mut stubs: Vec<u32> = Vec::with_capacity(checked_sum(degrees)? as usize);
214    let mut incomplete: BTreeSet<u32> = BTreeSet::new();
215
216    for outer in 0..MAX_OUTER_ATTEMPTS {
217        let _ = outer;
218        adj.iter_mut().for_each(Vec::clear);
219        residual.copy_from_slice(degrees);
220        let mut failed = false;
221        let mut finished;
222
223        loop {
224            // Build stubs from current residual_degrees.
225            stubs.clear();
226            for (i, &r) in residual.iter().enumerate() {
227                for _ in 0..r {
228                    stubs.push(i as u32);
229                }
230            }
231            // Reset residuals to zero; they'll be re-bumped on collision.
232            residual.fill(0);
233            incomplete.clear();
234
235            fisher_yates(&mut stubs, rng);
236
237            let k = stubs.len();
238            // Walk in pairs. Note: odd k would have been rejected earlier
239            // (graphical implies even Σd).
240            let mut i = 0;
241            while i + 1 < k {
242                let mut from = stubs[i];
243                let mut to = stubs[i + 1];
244                i += 2;
245                if from > to {
246                    std::mem::swap(&mut from, &mut to);
247                }
248                if from == to || adj_contains(&adj[from as usize], to) {
249                    residual[from as usize] = residual[from as usize].saturating_add(1);
250                    residual[to as usize] = residual[to as usize].saturating_add(1);
251                    incomplete.insert(from);
252                    incomplete.insert(to);
253                } else {
254                    adj_insert(&mut adj[from as usize], to);
255                }
256            }
257
258            finished = incomplete.is_empty();
259            if finished {
260                break;
261            }
262
263            // Check feasibility: can any pair of distinct incomplete
264            // vertices be connected by a non-existing edge?
265            let mut feasible = false;
266            let incs: Vec<u32> = incomplete.iter().copied().collect();
267            'outer_check: for ia in 0..incs.len() {
268                let mut from = incs[ia];
269                for ib in (ia + 1)..incs.len() {
270                    let mut to = incs[ib];
271                    if from > to {
272                        std::mem::swap(&mut from, &mut to);
273                    }
274                    if !adj_contains(&adj[from as usize], to) {
275                        feasible = true;
276                        break 'outer_check;
277                    }
278                    // restore (we mutated `from` via swap; reset per loop)
279                    from = incs[ia];
280                }
281            }
282            if !feasible {
283                failed = true;
284                break;
285            }
286            // Else loop again: rebuild stubs from current residuals.
287        }
288
289        if finished {
290            // Materialise sorted edge list.
291            let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(nu * 2);
292            for (u_idx, neis) in adj.iter().enumerate() {
293                let u = u_idx as u32;
294                for &v in neis {
295                    edges.push((u, v));
296                }
297            }
298            return Ok(edges);
299        }
300        let _ = failed;
301        // restart outer
302    }
303
304    Err(IgraphError::InvalidArgument(
305        "degree_sequence_game_fast_heur_simple: heuristic exhausted attempt cap; sequence appears to be hostile to the fast-heuristic; try the VL sampler".to_string(),
306    ))
307}
308
309/// Run the directed fast-heuristic loop. On success returns the directed
310/// edge list `(src, dst)` in adjacency-scan order (src ascending, dst
311/// ascending within each src).
312fn run_directed(
313    out_degrees: &[u32],
314    in_degrees: &[u32],
315    n: u32,
316    rng: &mut SplitMix64,
317) -> IgraphResult<Vec<(VertexId, VertexId)>> {
318    let nu = n as usize;
319    let mut residual_out: Vec<u32> = out_degrees.to_vec();
320    let mut residual_in: Vec<u32> = in_degrees.to_vec();
321    let mut adj: Adj = vec![Vec::new(); nu];
322    let out_total = checked_sum(out_degrees)? as usize;
323    let in_total = checked_sum(in_degrees)? as usize;
324    if out_total != in_total {
325        return Err(IgraphError::InvalidArgument(
326            "degree_sequence_game_fast_heur_simple: directed mode requires Σout == Σin".to_string(),
327        ));
328    }
329    let mut out_stubs: Vec<u32> = Vec::with_capacity(out_total);
330    let mut in_stubs: Vec<u32> = Vec::with_capacity(in_total);
331    let mut incomplete_out: BTreeSet<u32> = BTreeSet::new();
332    let mut incomplete_in: BTreeSet<u32> = BTreeSet::new();
333
334    for outer in 0..MAX_OUTER_ATTEMPTS {
335        let _ = outer;
336        adj.iter_mut().for_each(Vec::clear);
337        residual_out.copy_from_slice(out_degrees);
338        residual_in.copy_from_slice(in_degrees);
339        let mut failed = false;
340        let mut finished;
341
342        loop {
343            out_stubs.clear();
344            in_stubs.clear();
345            for (i, &r) in residual_out.iter().enumerate() {
346                for _ in 0..r {
347                    out_stubs.push(i as u32);
348                }
349            }
350            for (i, &r) in residual_in.iter().enumerate() {
351                for _ in 0..r {
352                    in_stubs.push(i as u32);
353                }
354            }
355            residual_out.fill(0);
356            residual_in.fill(0);
357            incomplete_out.clear();
358            incomplete_in.clear();
359
360            fisher_yates(&mut out_stubs, rng);
361
362            let k = out_stubs.len();
363            for i in 0..k {
364                let from = out_stubs[i];
365                let to = in_stubs[i];
366                if from == to || adj_contains(&adj[from as usize], to) {
367                    residual_out[from as usize] = residual_out[from as usize].saturating_add(1);
368                    residual_in[to as usize] = residual_in[to as usize].saturating_add(1);
369                    incomplete_out.insert(from);
370                    incomplete_in.insert(to);
371                } else {
372                    adj_insert(&mut adj[from as usize], to);
373                }
374            }
375
376            finished = incomplete_out.is_empty();
377            if finished {
378                break;
379            }
380            // Feasibility test: any (from, to) with from ∈ inc_out,
381            // to ∈ inc_in, from != to, and edge not yet present.
382            let mut feasible = false;
383            'outer_check: for &from in &incomplete_out {
384                for &to in &incomplete_in {
385                    if from != to && !adj_contains(&adj[from as usize], to) {
386                        feasible = true;
387                        break 'outer_check;
388                    }
389                }
390            }
391            if !feasible {
392                failed = true;
393                break;
394            }
395        }
396
397        if finished {
398            let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(out_total);
399            for (u_idx, neis) in adj.iter().enumerate() {
400                let u = u_idx as u32;
401                for &v in neis {
402                    edges.push((u, v));
403                }
404            }
405            return Ok(edges);
406        }
407        let _ = failed;
408    }
409
410    Err(IgraphError::InvalidArgument(
411        "degree_sequence_game_fast_heur_simple: heuristic exhausted attempt cap (directed); sequence appears to be hostile to the fast-heuristic".to_string(),
412    ))
413}
414
415/// Sample a simple graph realising the given degree sequence(s) via the
416/// fast-heuristic single-pass + restart algorithm.
417///
418/// * `out_degrees` — undirected degree of every vertex, or out-degree of
419///   every vertex when `in_degrees = Some(...)`. Vertex count is the
420///   slice length.
421/// * `in_degrees` — when `Some(seq)`, switches to directed mode with
422///   `seq[i]` as the in-degree of vertex `i`; must equal `out_degrees`
423///   in length and sum.
424/// * `seed` — drives the internal `SplitMix64` PRNG.
425///
426/// Output is a simple graph (no self-loops, no multi-edges). The sampled
427/// graph is *not* uniformly distributed on the space of simple
428/// realisations — see [`degree_sequence_game_vl`] for a uniform sampler.
429///
430/// # Errors
431///
432/// * `n > u32::MAX`.
433/// * `in_degrees.len() != out_degrees.len()`.
434/// * Undirected mode with a non-graphical sequence (Erdős–Gallai fails).
435/// * Directed mode with `Σ out ≠ Σ in` or a non-graphical pair
436///   (Fulkerson–Chen–Anstee fails).
437/// * Heuristic exhausted `MAX_OUTER_ATTEMPTS` (1024) restarts.
438///
439/// [`degree_sequence_game_vl`]: crate::degree_sequence_game_vl
440///
441/// # Examples
442///
443/// ```
444/// use rust_igraph::degree_sequence_game_fast_heur_simple;
445///
446/// // 4-cycle: every vertex degree 2 ⇒ 4 simple edges.
447/// let g = degree_sequence_game_fast_heur_simple(&[2, 2, 2, 2], None, 7).unwrap();
448/// assert_eq!(g.vcount(), 4);
449/// assert_eq!(g.ecount(), 4);
450/// assert!(!g.is_directed());
451/// ```
452pub fn degree_sequence_game_fast_heur_simple(
453    out_degrees: &[u32],
454    in_degrees: Option<&[u32]>,
455    seed: u64,
456) -> IgraphResult<Graph> {
457    let directed = in_degrees.is_some();
458    let n = u32::try_from(out_degrees.len())
459        .map_err(|_| IgraphError::Internal("vertex count exceeds u32"))?;
460    if let Some(in_seq) = in_degrees {
461        if in_seq.len() != out_degrees.len() {
462            return Err(IgraphError::InvalidArgument(
463                "degree_sequence_game_fast_heur_simple: out_degrees and in_degrees must have the same length".to_string(),
464            ));
465        }
466    }
467
468    // Graphicality checks.
469    if directed {
470        let Some(in_seq) = in_degrees else {
471            return Err(IgraphError::InvalidArgument(
472                "directed graph requires in_degrees".to_string(),
473            ));
474        };
475        if !is_graphical_simple_directed(out_degrees, in_seq) {
476            return Err(IgraphError::InvalidArgument(
477                "degree_sequence_game_fast_heur_simple: degree pair is not realisable as a simple directed graph (Fulkerson–Chen–Anstee)"
478                    .to_string(),
479            ));
480        }
481    } else if !is_graphical_simple_undirected(out_degrees) {
482        return Err(IgraphError::InvalidArgument(
483            "degree_sequence_game_fast_heur_simple: degree sequence is not realisable as a simple undirected graph (Erdős–Gallai)".to_string(),
484        ));
485    }
486
487    let mut rng = SplitMix64::new(seed);
488
489    let edges = if directed {
490        let Some(in_seq) = in_degrees else {
491            return Err(IgraphError::InvalidArgument(
492                "directed graph requires in_degrees".to_string(),
493            ));
494        };
495        run_directed(out_degrees, in_seq, n, &mut rng)?
496    } else {
497        run_undirected(out_degrees, n, &mut rng)?
498    };
499
500    let mut g = Graph::new(n, directed)?;
501    g.add_edges(edges)?;
502    Ok(g)
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508    use crate::algorithms::properties::{SimpleMode, is_simple_with_mode};
509
510    fn observed_degrees(g: &Graph) -> Vec<u32> {
511        let n = g.vcount() as usize;
512        let mut deg = vec![0u32; n];
513        let ec = u32::try_from(g.ecount()).expect("ecount fits u32");
514        for eid in 0..ec {
515            let (s, t) = g.edge(eid).expect("eid in bounds");
516            deg[s as usize] = deg[s as usize].saturating_add(1);
517            deg[t as usize] = deg[t as usize].saturating_add(1);
518        }
519        deg
520    }
521
522    fn observed_out_in(g: &Graph) -> (Vec<u32>, Vec<u32>) {
523        let n = g.vcount() as usize;
524        let mut out = vec![0u32; n];
525        let mut inv = vec![0u32; n];
526        let ec = u32::try_from(g.ecount()).expect("ecount fits u32");
527        for eid in 0..ec {
528            let (s, t) = g.edge(eid).expect("eid in bounds");
529            out[s as usize] = out[s as usize].saturating_add(1);
530            inv[t as usize] = inv[t as usize].saturating_add(1);
531        }
532        (out, inv)
533    }
534
535    #[test]
536    fn undirected_empty_sequence_yields_empty_graph() {
537        let g = degree_sequence_game_fast_heur_simple(&[], None, 1).expect("empty ok");
538        assert_eq!(g.vcount(), 0);
539        assert_eq!(g.ecount(), 0);
540        assert!(!g.is_directed());
541    }
542
543    #[test]
544    fn undirected_singleton_zero_yields_isolated_vertex() {
545        let g = degree_sequence_game_fast_heur_simple(&[0], None, 1).expect("singleton ok");
546        assert_eq!(g.vcount(), 1);
547        assert_eq!(g.ecount(), 0);
548    }
549
550    #[test]
551    fn undirected_4cycle_preserves_degrees_and_is_simple() {
552        let g = degree_sequence_game_fast_heur_simple(&[2, 2, 2, 2], None, 7).expect("ok");
553        assert_eq!(g.vcount(), 4);
554        assert_eq!(g.ecount(), 4);
555        assert_eq!(observed_degrees(&g), vec![2, 2, 2, 2]);
556        assert!(is_simple_with_mode(&g, SimpleMode::DirectedAsDirected).unwrap());
557    }
558
559    #[test]
560    fn undirected_3regular_n6_preserves_degrees() {
561        let degrees: Vec<u32> = vec![3; 6];
562        let g = degree_sequence_game_fast_heur_simple(&degrees, None, 0xABCD_u64).expect("ok");
563        assert_eq!(observed_degrees(&g), degrees);
564        assert!(is_simple_with_mode(&g, SimpleMode::DirectedAsDirected).unwrap());
565    }
566
567    #[test]
568    fn undirected_complete_k4_preserves_degrees() {
569        let g = degree_sequence_game_fast_heur_simple(&[3, 3, 3, 3], None, 42).expect("ok");
570        assert_eq!(g.ecount(), 6);
571        assert_eq!(observed_degrees(&g), vec![3, 3, 3, 3]);
572        assert!(is_simple_with_mode(&g, SimpleMode::DirectedAsDirected).unwrap());
573    }
574
575    #[test]
576    fn undirected_odd_sum_rejected() {
577        let err = degree_sequence_game_fast_heur_simple(&[1, 1, 1], None, 1).unwrap_err();
578        match err {
579            IgraphError::InvalidArgument(msg) => assert!(msg.contains("Erdős–Gallai")),
580            other => panic!("unexpected error: {other:?}"),
581        }
582    }
583
584    #[test]
585    fn undirected_degree_exceeds_n_minus_1_rejected() {
586        // n=4, max simple-graph degree = n-1 = 3.
587        // [5,3,1,1] has Σ=10 (even) but vertex 0's degree 5 > 3.
588        // EG, k=1: 5 ≤ 0 + min(3,1)+min(1,1)+min(1,1) = 3 → fails.
589        let err = degree_sequence_game_fast_heur_simple(&[5, 3, 1, 1], None, 1).unwrap_err();
590        match err {
591            IgraphError::InvalidArgument(msg) => assert!(msg.contains("Erdős–Gallai")),
592            other => panic!("unexpected error: {other:?}"),
593        }
594    }
595
596    #[test]
597    fn undirected_non_graphical_rejected() {
598        // [3,3,3,1] has sum 10 (even), max=3, n=4. Sorted desc: [3,3,3,1].
599        // k=1: 3 ≤ 0 + min(3,1)+min(3,1)+min(1,1) = 1+1+1 = 3, ok.
600        // k=2: 6 ≤ 2 + min(3,2)+min(1,2) = 2 + 3 = 5. FAILS.
601        let err = degree_sequence_game_fast_heur_simple(&[3, 3, 3, 1], None, 1).unwrap_err();
602        match err {
603            IgraphError::InvalidArgument(msg) => assert!(msg.contains("Erdős–Gallai")),
604            other => panic!("unexpected error: {other:?}"),
605        }
606    }
607
608    #[test]
609    fn undirected_skewed_sequence_preserves_degrees() {
610        let degrees: Vec<u32> = vec![5, 4, 4, 3, 3, 3, 2, 2, 2, 2];
611        let g = degree_sequence_game_fast_heur_simple(&degrees, None, 123).expect("ok");
612        assert_eq!(observed_degrees(&g), degrees);
613        assert!(is_simple_with_mode(&g, SimpleMode::DirectedAsDirected).unwrap());
614    }
615
616    #[test]
617    fn undirected_same_seed_same_graph() {
618        let degrees: Vec<u32> = vec![3, 3, 3, 3, 2, 2];
619        let a = degree_sequence_game_fast_heur_simple(&degrees, None, 9).expect("a");
620        let b = degree_sequence_game_fast_heur_simple(&degrees, None, 9).expect("b");
621        let ea: Vec<(u32, u32)> = (0..a.ecount() as u32)
622            .map(|e| a.edge(e).expect("eid"))
623            .collect();
624        let eb: Vec<(u32, u32)> = (0..b.ecount() as u32)
625            .map(|e| b.edge(e).expect("eid"))
626            .collect();
627        assert_eq!(ea, eb);
628    }
629
630    #[test]
631    fn undirected_distinct_seeds_likely_differ() {
632        let degrees: Vec<u32> = vec![3; 10];
633        let mut a_edges: Vec<(u32, u32)> = (0..)
634            .take(
635                degree_sequence_game_fast_heur_simple(&degrees, None, 1)
636                    .expect("a")
637                    .ecount() as usize,
638            )
639            .map(|_| (0u32, 0u32))
640            .collect();
641        let g_a = degree_sequence_game_fast_heur_simple(&degrees, None, 1).expect("a");
642        for e in 0..g_a.ecount() as u32 {
643            a_edges[e as usize] = g_a.edge(e).expect("eid");
644        }
645        let g_b = degree_sequence_game_fast_heur_simple(&degrees, None, 2).expect("b");
646        let b_edges: Vec<(u32, u32)> = (0..g_b.ecount() as u32)
647            .map(|e| g_b.edge(e).expect("eid"))
648            .collect();
649        // They might match by chance but with 3-regular n=10 the chance is vanishing.
650        assert_ne!(a_edges, b_edges);
651    }
652
653    // --- directed ---
654
655    #[test]
656    fn directed_balanced_d1_n4_cycle() {
657        // out=[1,1,1,1], in=[1,1,1,1] ⇒ one 4-cycle realisation up to permutation.
658        let g = degree_sequence_game_fast_heur_simple(&[1, 1, 1, 1], Some(&[1, 1, 1, 1]), 1)
659            .expect("ok");
660        assert!(g.is_directed());
661        assert_eq!(g.vcount(), 4);
662        assert_eq!(g.ecount(), 4);
663        let (out, inv) = observed_out_in(&g);
664        assert_eq!(out, vec![1, 1, 1, 1]);
665        assert_eq!(inv, vec![1, 1, 1, 1]);
666        assert!(is_simple_with_mode(&g, SimpleMode::DirectedAsDirected).unwrap());
667    }
668
669    #[test]
670    fn directed_skewed_n5_preserves_both_degrees() {
671        let out_seq: Vec<u32> = vec![2, 2, 2, 1, 1];
672        let in_seq: Vec<u32> = vec![1, 1, 2, 2, 2];
673        let g = degree_sequence_game_fast_heur_simple(&out_seq, Some(&in_seq), 0x5EED).expect("ok");
674        let (out, inv) = observed_out_in(&g);
675        assert_eq!(out, out_seq);
676        assert_eq!(inv, in_seq);
677        assert!(is_simple_with_mode(&g, SimpleMode::DirectedAsDirected).unwrap());
678    }
679
680    #[test]
681    fn directed_sum_mismatch_rejected() {
682        let err =
683            degree_sequence_game_fast_heur_simple(&[1, 1, 1], Some(&[1, 1, 0]), 1).unwrap_err();
684        match err {
685            IgraphError::InvalidArgument(msg) => {
686                assert!(msg.contains("Fulkerson") || msg.contains("Σout"));
687            }
688            other => panic!("unexpected error: {other:?}"),
689        }
690    }
691
692    #[test]
693    fn directed_length_mismatch_rejected() {
694        let err = degree_sequence_game_fast_heur_simple(&[1, 1, 1], Some(&[1, 1]), 1).unwrap_err();
695        match err {
696            IgraphError::InvalidArgument(msg) => assert!(msg.contains("length")),
697            other => panic!("unexpected error: {other:?}"),
698        }
699    }
700
701    #[test]
702    fn directed_self_loops_avoided_when_residuals_permit() {
703        // Force the only realisation to be 0→1, 1→0 (no self-loops).
704        let g = degree_sequence_game_fast_heur_simple(&[1, 1], Some(&[1, 1]), 1).expect("ok");
705        assert_eq!(g.ecount(), 2);
706        assert!(is_simple_with_mode(&g, SimpleMode::DirectedAsDirected).unwrap());
707    }
708
709    #[test]
710    fn directed_3regular_n5_preserves_degrees() {
711        let degrees: Vec<u32> = vec![3; 5];
712        let g =
713            degree_sequence_game_fast_heur_simple(&degrees, Some(&degrees), 0xC0DE).expect("ok");
714        let (out, inv) = observed_out_in(&g);
715        assert_eq!(out, degrees);
716        assert_eq!(inv, degrees);
717        assert!(is_simple_with_mode(&g, SimpleMode::DirectedAsDirected).unwrap());
718    }
719}
720
721#[cfg(all(test, feature = "proptest-harness"))]
722mod proptests {
723    use super::*;
724    use crate::algorithms::properties::{SimpleMode, is_simple_with_mode};
725    use proptest::prelude::*;
726
727    fn observed_degrees(g: &Graph) -> Vec<u32> {
728        let n = g.vcount() as usize;
729        let mut deg = vec![0u32; n];
730        let ec = u32::try_from(g.ecount()).expect("ecount fits u32");
731        for eid in 0..ec {
732            let (s, t) = g.edge(eid).expect("eid in bounds");
733            deg[s as usize] = deg[s as usize].saturating_add(1);
734            deg[t as usize] = deg[t as usize].saturating_add(1);
735        }
736        deg
737    }
738
739    fn arb_undirected_degseq() -> impl Strategy<Value = Vec<u32>> {
740        (3usize..=10).prop_flat_map(|n| prop::collection::vec(0u32..(n as u32), n))
741    }
742
743    proptest! {
744        #![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })]
745
746        #[test]
747        fn degrees_preserved_when_graphical(seq in arb_undirected_degseq(), seed in any::<u64>()) {
748            let result = degree_sequence_game_fast_heur_simple(&seq, None, seed);
749            match result {
750                Ok(g) => {
751                    prop_assert_eq!(observed_degrees(&g), seq);
752                    prop_assert!(is_simple_with_mode(&g, SimpleMode::DirectedAsDirected).unwrap());
753                }
754                Err(IgraphError::InvalidArgument(msg)) => {
755                    // Acceptable only when truly non-graphical or the heuristic capped out.
756                    prop_assert!(
757                        msg.contains("Erdős–Gallai") || msg.contains("attempt cap"),
758                        "unexpected rejection: {}",
759                        msg
760                    );
761                }
762                Err(other) => prop_assert!(false, "unexpected error: {:?}", other),
763            }
764        }
765
766        #[test]
767        fn same_seed_same_graph(seq in arb_undirected_degseq(), seed in any::<u64>()) {
768            let a = degree_sequence_game_fast_heur_simple(&seq, None, seed);
769            let b = degree_sequence_game_fast_heur_simple(&seq, None, seed);
770            match (a, b) {
771                (Ok(ga), Ok(gb)) => {
772                    let ea: Vec<(u32, u32)> = (0..ga.ecount() as u32)
773                        .map(|e| ga.edge(e).expect("eid"))
774                        .collect();
775                    let eb: Vec<(u32, u32)> = (0..gb.ecount() as u32)
776                        .map(|e| gb.edge(e).expect("eid"))
777                        .collect();
778                    prop_assert_eq!(ea, eb);
779                }
780                (Err(_), Err(_)) => {}
781                (a, b) => prop_assert!(false, "determinism violated: {:?} vs {:?}", a, b),
782            }
783        }
784    }
785}