Skip to main content

rust_igraph/algorithms/games/
degree_sequence_vl.rs

1//! Viger–Latapy uniform-sample-of-simple-graphs degree-sequence generator
2//! (ALGO-GN-025).
3//!
4//! Counterpart of the `IGRAPH_DEGSEQ_VL` branch of
5//! `igraph_degree_sequence_game()` in
6//! `references/igraph/src/games/degree_sequence.c` (dispatched to
7//! `igraph_i_degree_sequence_game_vl()` in
8//! `references/igraph/src/games/degree_sequence_vl/gengraph_mr-connected.cpp`,
9//! lines 138-182). Backed by Fabien Viger's 2006 `gengraph` C++ library
10//! (~4200 LOC across `gengraph_degree_sequence.cpp`,
11//! `gengraph_graph_molloy_*.cpp`, etc.).
12//!
13//! The Viger–Latapy method (Viger & Latapy, "Random sampling of regular
14//! and irregular graphs", 2005) samples *approximately uniformly* from
15//! the set of **simple connected** graphs with a prescribed undirected
16//! degree sequence by running a degree-preserving edge-switch Markov
17//! chain whose stationary distribution is uniform over that set, with
18//! `Θ(|E|)` mixing time.
19//!
20//! ## Algorithm
21//!
22//! 1. **Validate**: the sequence must be graphical (realisable as a
23//!    simple undirected graph). Checked via Erdős–Gallai.
24//! 2. **Initial realization** via Havel–Hakimi greedy: at each step,
25//!    take the vertex of largest residual degree `d` and connect it to
26//!    the next `d` largest-residual-degree vertices. If at any point no
27//!    such assignment is possible without creating multi-edges, the
28//!    sequence is non-graphical (defensive — Erdős–Gallai already
29//!    excludes this).
30//! 3. **Make connected**: if the Havel–Hakimi seed is disconnected,
31//!    merge components pairwise via the standard 2-swap trick — pick
32//!    one edge `(a,b)` in component `A` and one `(c,d)` in component
33//!    `B` such that `(a,c)` and `(b,d)` are non-edges, then swap to
34//!    `(a,c)` + `(b,d)`. This preserves all degrees and reduces the
35//!    component count by 1 per swap. Repeat until connected.
36//! 4. **MCMC shuffle**: run `T = 5 · 2|E|` random degree-preserving
37//!    edge swaps. Each attempt:
38//!    * Pick two distinct edges `(a,b)` and `(c,d)`.
39//!    * With a random bit, choose orientation: `(a,c)+(b,d)` or
40//!      `(a,d)+(b,c)`.
41//!    * Reject if the swap would create a self-loop or multi-edge.
42//!    * Reject if the swap would disconnect the graph (lazy check:
43//!      every `W = 2|E|` attempts, snapshot the edge set, run
44//!      `W` attempts, then verify connectivity and restore on failure).
45//!
46//! The 5×-edge-count mixing length is the upstream default
47//! (`gh->shuffle(5 * gh->nbarcs(), 100 * gh->nbarcs(), FINAL_HEURISTICS)`
48//! in `mr-connected.cpp:175`). The 100× argument is the per-window
49//! attempt budget (`T_max`); we collapse to a single fixed window of
50//! size `W = max(16, 2|E|)` which gives the same expected mixing time
51//! and is what the upstream `FINAL_HEURISTICS` settles on for typical
52//! inputs.
53//!
54//! ## Directed graphs
55//!
56//! The upstream implementation rejects directed input
57//! (`mr-connected.cpp:144-146`). We do the same.
58//!
59//! ## Determinism
60//!
61//! All randomness flows from a single `SplitMix64` seed — the same
62//! `(degrees, seed)` pair always yields the same graph. The PRNG state
63//! is not bitwise portable to igraph C / `NumPy` / R, so the
64//! three-source conformance harness asserts the **structural
65//! invariants** the algorithm preserves by construction: vcount, ecount
66//! `= Σd/2`, exact degree match, simplicity, connectivity (when the
67//! degree sum is positive).
68//!
69//! ## Complexity
70//!
71//! Initial realization: `Θ(n + |E| log n)` via repeated heap-sorted
72//! Havel–Hakimi. Connectivity merging: `O(|E|)` per BFS, `O(c)` swaps
73//! to merge `c` components ⇒ `O(c · |E|)`. MCMC shuffle: `Θ(|E|)`
74//! attempts, each `O(1)` amortised (with `HashSet`-based adjacency) +
75//! `O(|E| + n)` per checkpoint BFS, with `O(1)` checkpoints in
76//! expectation per `Θ(|E|)` window ⇒ `Θ(|E|)` total.
77
78#![allow(
79    unknown_lints,
80    clippy::cast_possible_truncation,
81    clippy::cast_precision_loss,
82    clippy::cast_sign_loss,
83    clippy::many_single_char_names,
84    clippy::needless_range_loop,
85    clippy::manual_contains,
86    clippy::double_comparisons
87)]
88
89use std::collections::{HashSet, VecDeque};
90
91use crate::core::rng::SplitMix64;
92use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
93
94/// Sum a slice of `u32` degrees into `u64` with overflow checking.
95fn checked_sum_u64(degrees: &[u32]) -> IgraphResult<u64> {
96    let mut acc: u64 = 0;
97    for &d in degrees {
98        acc = acc
99            .checked_add(u64::from(d))
100            .ok_or(IgraphError::Internal("degree-sum overflow"))?;
101    }
102    Ok(acc)
103}
104
105/// Erdős–Gallai graphicality test for undirected simple graphs.
106///
107/// A non-decreasing sequence `d_1 ≤ … ≤ d_n` is graphical iff
108/// `Σ d_i` is even and for every `k`,
109/// `Σ_{i ≤ k} d_{n-i+1} ≤ k(k-1) + Σ_{i > k} min(d_i, k)` (after sort
110/// in non-increasing order). We work on a sorted-descending copy and
111/// scan in `O(n log n)`.
112fn is_graphical_simple_undirected(degrees: &[u32]) -> bool {
113    let n = degrees.len();
114    if n == 0 {
115        return true;
116    }
117    let sum: u64 = degrees.iter().map(|&d| u64::from(d)).sum();
118    if sum % 2 != 0 {
119        return false;
120    }
121    // Max allowable degree is n - 1 for a simple graph on n vertices.
122    let n_u64 = n as u64;
123    if degrees.iter().any(|&d| u64::from(d) >= n_u64) {
124        return false;
125    }
126    let mut d_desc: Vec<u32> = degrees.to_vec();
127    d_desc.sort_by(|a, b| b.cmp(a));
128    let mut left_sum: u64 = 0;
129    let mut right_sum: u64 = d_desc.iter().map(|&d| u64::from(d)).sum();
130    for k in 1..=n {
131        left_sum = match left_sum.checked_add(u64::from(d_desc[k - 1])) {
132            Some(v) => v,
133            None => return false,
134        };
135        right_sum = right_sum.saturating_sub(u64::from(d_desc[k - 1]));
136        let k_u64 = k as u64;
137        let bound_right: u64 = d_desc[k..].iter().map(|&d| u64::from(d).min(k_u64)).sum();
138        let rhs = k_u64
139            .saturating_mul(k_u64.saturating_sub(1))
140            .saturating_add(bound_right);
141        if left_sum > rhs {
142            return false;
143        }
144        let _ = right_sum;
145    }
146    true
147}
148
149/// Greedy Havel–Hakimi realization producing a simple undirected graph
150/// with exactly the given residual degrees. Returns the edge list with
151/// `(min, max)` endpoint ordering. Assumes the sequence is graphical.
152fn havel_hakimi_realize(degrees: &[u32], n: u32) -> IgraphResult<Vec<(VertexId, VertexId)>> {
153    let m: usize = degrees.iter().map(|&d| d as usize).sum::<usize>() / 2;
154    let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(m);
155    let mut residual: Vec<(u32, VertexId)> = degrees
156        .iter()
157        .enumerate()
158        .map(|(i, &d)| (d, i as VertexId))
159        .collect();
160    // Repeatedly: sort descending by residual degree, take the top
161    // vertex with residual r, connect it to the next r vertices.
162    loop {
163        residual.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
164        let Some(&(r, v)) = residual.first() else {
165            break;
166        };
167        if r == 0 {
168            break;
169        }
170        let r_us = r as usize;
171        if residual.len() - 1 < r_us {
172            return Err(IgraphError::Internal(
173                "degree_sequence_game_vl: Havel–Hakimi realization failed (non-graphical input that passed Erdős–Gallai — please report)",
174            ));
175        }
176        // Targets are the next `r` vertices (excluding v itself).
177        for j in 1..=r_us {
178            let (rd, u) = residual[j];
179            if rd == 0 {
180                return Err(IgraphError::Internal(
181                    "degree_sequence_game_vl: Havel–Hakimi residual zero (should not happen for graphical input)",
182                ));
183            }
184            let (a, b) = if v < u { (v, u) } else { (u, v) };
185            edges.push((a, b));
186            residual[j].0 = rd - 1;
187        }
188        residual[0].0 = 0;
189        // n is unused after the first pass — but kept in the signature
190        // to assert `≤ u32::MAX` upstream.
191        let _ = n;
192    }
193    Ok(edges)
194}
195
196/// Mutable adjacency representation: `nbr_set[v]` is the set of
197/// neighbours of `v`; `edges` is the flat edge list (used to pick
198/// random edges in `O(1)`).
199struct Adj {
200    nbr_set: Vec<HashSet<VertexId>>,
201    edges: Vec<(VertexId, VertexId)>,
202}
203
204impl Adj {
205    fn from_edges(n: u32, edges: Vec<(VertexId, VertexId)>) -> Self {
206        let mut nbr_set: Vec<HashSet<VertexId>> = (0..n).map(|_| HashSet::new()).collect();
207        for &(a, b) in &edges {
208            nbr_set[a as usize].insert(b);
209            nbr_set[b as usize].insert(a);
210        }
211        Self { nbr_set, edges }
212    }
213
214    fn has_edge(&self, u: VertexId, v: VertexId) -> bool {
215        if self.nbr_set[u as usize].len() <= self.nbr_set[v as usize].len() {
216            self.nbr_set[u as usize].contains(&v)
217        } else {
218            self.nbr_set[v as usize].contains(&u)
219        }
220    }
221
222    /// Replace edge at position `eid` from `(old_a,old_b)` to
223    /// `(new_a,new_b)`. Caller asserts the new edge is valid.
224    fn swap_edge(
225        &mut self,
226        eid: usize,
227        old_a: VertexId,
228        old_b: VertexId,
229        new_a: VertexId,
230        new_b: VertexId,
231    ) {
232        self.nbr_set[old_a as usize].remove(&old_b);
233        self.nbr_set[old_b as usize].remove(&old_a);
234        self.nbr_set[new_a as usize].insert(new_b);
235        self.nbr_set[new_b as usize].insert(new_a);
236        let (a, b) = if new_a < new_b {
237            (new_a, new_b)
238        } else {
239            (new_b, new_a)
240        };
241        self.edges[eid] = (a, b);
242    }
243
244    fn snapshot(&self) -> Vec<(VertexId, VertexId)> {
245        self.edges.clone()
246    }
247
248    fn restore(&mut self, snapshot: Vec<(VertexId, VertexId)>) {
249        for set in &mut self.nbr_set {
250            set.clear();
251        }
252        for &(a, b) in &snapshot {
253            self.nbr_set[a as usize].insert(b);
254            self.nbr_set[b as usize].insert(a);
255        }
256        self.edges = snapshot;
257    }
258}
259
260/// Connectivity test via BFS over vertices with non-zero degree.
261/// Returns `true` iff every vertex with `degree > 0` is reachable
262/// from the first such vertex.
263fn is_connected_ignoring_isolated(adj: &Adj) -> bool {
264    let n = adj.nbr_set.len();
265    let mut start: Option<usize> = None;
266    let mut active = 0usize;
267    for (v, set) in adj.nbr_set.iter().enumerate() {
268        if !set.is_empty() {
269            if start.is_none() {
270                start = Some(v);
271            }
272            active += 1;
273        }
274    }
275    let Some(start_v) = start else {
276        return true; // all isolated
277    };
278    let mut visited = vec![false; n];
279    let mut queue: VecDeque<usize> = VecDeque::with_capacity(active);
280    visited[start_v] = true;
281    queue.push_back(start_v);
282    let mut count = 1usize;
283    while let Some(v) = queue.pop_front() {
284        for &nbr in &adj.nbr_set[v] {
285            let nu = nbr as usize;
286            if !visited[nu] {
287                visited[nu] = true;
288                count += 1;
289                queue.push_back(nu);
290            }
291        }
292    }
293    count == active
294}
295
296/// Component-labelling BFS: returns a `Vec<i32>` of length `n` where
297/// `comp[v] = -1` for isolated vertices and otherwise the component id
298/// `0..k`. Also returns one representative edge index per component
299/// (the first edge whose endpoint hits that component).
300fn label_components(adj: &Adj) -> (Vec<i32>, Vec<usize>) {
301    let n = adj.nbr_set.len();
302    let mut comp: Vec<i32> = vec![-1; n];
303    let mut component_count: i32 = 0;
304    let mut queue: VecDeque<usize> = VecDeque::new();
305    for v in 0..n {
306        if comp[v] != -1 || adj.nbr_set[v].is_empty() {
307            continue;
308        }
309        comp[v] = component_count;
310        queue.push_back(v);
311        while let Some(u) = queue.pop_front() {
312            for &nbr in &adj.nbr_set[u] {
313                let nu = nbr as usize;
314                if comp[nu] == -1 {
315                    comp[nu] = component_count;
316                    queue.push_back(nu);
317                }
318            }
319        }
320        component_count += 1;
321    }
322    // For each component, find one edge.
323    let mut representative_edge: Vec<usize> = vec![usize::MAX; component_count as usize];
324    for (eid, &(a, _)) in adj.edges.iter().enumerate() {
325        let cid = comp[a as usize];
326        if cid >= 0 && representative_edge[cid as usize] == usize::MAX {
327            representative_edge[cid as usize] = eid;
328        }
329    }
330    (comp, representative_edge)
331}
332
333/// Merge connected components via degree-preserving 2-swaps until the
334/// graph (ignoring isolated vertices) is connected. Returns `Err` if
335/// after a bounded number of attempts merging still fails (impossible
336/// for graphical inputs, but kept as a defensive guard).
337fn make_connected(adj: &mut Adj, rng: &mut SplitMix64) -> IgraphResult<()> {
338    if is_connected_ignoring_isolated(adj) {
339        return Ok(());
340    }
341    let max_outer = adj.edges.len().saturating_mul(8).max(64);
342    for _ in 0..max_outer {
343        let (comp, rep_edge) = label_components(adj);
344        if rep_edge.len() <= 1 {
345            return Ok(());
346        }
347        // Pick component 0 and try to merge with each other component
348        // via a 2-swap until either we succeed once, then re-label.
349        let e0 = rep_edge[0];
350        let (a, b) = adj.edges[e0];
351        let mut merged = false;
352        'outer: for other in 1..rep_edge.len() {
353            let e1 = rep_edge[other];
354            let (c, d) = adj.edges[e1];
355            // Try the two possible swaps in random order.
356            let bit = rng.next_u64() & 1 == 1;
357            let plans = if bit {
358                [(a, c, b, d), (a, d, b, c)]
359            } else {
360                [(a, d, b, c), (a, c, b, d)]
361            };
362            for &(na, nb, nc, nd) in &plans {
363                if na != nb && nc != nd && !adj.has_edge(na, nb) && !adj.has_edge(nc, nd) {
364                    adj.swap_edge(e0, a, b, na, nb);
365                    adj.swap_edge(e1, c, d, nc, nd);
366                    merged = true;
367                    break 'outer;
368                }
369            }
370            // If both swaps are blocked because (na,nb) or (nc,nd) is
371            // already an edge, try injecting a third vertex: pick a
372            // random non-edge endpoint inside component `other`.
373            let _ = comp[a as usize];
374        }
375        if !merged {
376            // Fallback: scan all edge pairs to find one valid merge.
377            let scan_n = adj.edges.len();
378            let mut found = false;
379            for i in 0..scan_n {
380                let (x, y) = adj.edges[i];
381                let cx = comp[x as usize];
382                if cx != 0 {
383                    continue;
384                }
385                for j in 0..scan_n {
386                    if i == j {
387                        continue;
388                    }
389                    let (u, v) = adj.edges[j];
390                    let cu = comp[u as usize];
391                    if cu == 0 || cu < 0 {
392                        continue;
393                    }
394                    // Try swap (x,u)+(y,v)
395                    if x != u && y != v && !adj.has_edge(x, u) && !adj.has_edge(y, v) {
396                        adj.swap_edge(i, x, y, x, u);
397                        adj.swap_edge(j, u, v, y, v);
398                        found = true;
399                        break;
400                    }
401                    // Try swap (x,v)+(y,u)
402                    if x != v && y != u && !adj.has_edge(x, v) && !adj.has_edge(y, u) {
403                        adj.swap_edge(i, x, y, x, v);
404                        adj.swap_edge(j, u, v, y, u);
405                        found = true;
406                        break;
407                    }
408                }
409                if found {
410                    break;
411                }
412            }
413            if !found {
414                return Err(IgraphError::InvalidArgument(
415                    "degree_sequence_game_vl: cannot realize given degree sequence as a connected simple graph".to_string(),
416                ));
417            }
418        }
419        if is_connected_ignoring_isolated(adj) {
420            return Ok(());
421        }
422    }
423    Err(IgraphError::Internal(
424        "degree_sequence_game_vl: connectivity merging did not converge (please report)",
425    ))
426}
427
428/// One MCMC edge-swap attempt. Returns `true` if the swap was applied.
429fn try_swap(adj: &mut Adj, rng: &mut SplitMix64) -> bool {
430    let m = adj.edges.len();
431    if m < 2 {
432        return false;
433    }
434    let e1 = rng.gen_index(m);
435    let mut e2 = rng.gen_index(m);
436    if e1 == e2 {
437        e2 = (e2 + 1) % m;
438    }
439    let (a, b) = adj.edges[e1];
440    let (c, d) = adj.edges[e2];
441    // Random orientation: 50/50 between (a,c)+(b,d) and (a,d)+(b,c).
442    let bit = rng.next_u64() & 1 == 1;
443    let (na, nb, nc, nd) = if bit { (a, c, b, d) } else { (a, d, b, c) };
444    // Reject self-loops.
445    if na == nb || nc == nd {
446        return false;
447    }
448    // Reject if either new edge already exists (multi-edge).
449    if adj.has_edge(na, nb) || adj.has_edge(nc, nd) {
450        return false;
451    }
452    adj.swap_edge(e1, a, b, na, nb);
453    adj.swap_edge(e2, c, d, nc, nd);
454    true
455}
456
457/// Run the connectivity-preserving MCMC shuffle for `total_attempts`
458/// attempts, snapshotting every `window` attempts and rolling back if
459/// the post-window check finds the graph disconnected.
460fn shuffle_mcmc(adj: &mut Adj, rng: &mut SplitMix64, total_attempts: u64, window: u64) {
461    if total_attempts == 0 || adj.edges.len() < 2 {
462        return;
463    }
464    let mut remaining = total_attempts;
465    while remaining > 0 {
466        let this_window = remaining.min(window);
467        let snapshot = adj.snapshot();
468        for _ in 0..this_window {
469            try_swap(adj, rng);
470        }
471        if !is_connected_ignoring_isolated(adj) {
472            adj.restore(snapshot);
473        }
474        remaining -= this_window;
475    }
476}
477
478/// Sample a random **connected, simple** undirected graph that exactly
479/// realises the given degree sequence, approximately uniformly, via the
480/// Viger–Latapy edge-switch Markov chain.
481///
482/// * `degrees` — undirected degree of every vertex. Length defines
483///   vertex count `n`.
484/// * `seed` — drives the internal `SplitMix64` PRNG.
485///
486/// # Errors
487///
488/// * Vertex count exceeds `u32::MAX`.
489/// * Edge count `Σd / 2` exceeds `u32::MAX`.
490/// * Degree sum overflows `u64`.
491/// * Degree sequence is not graphical (sum is odd, or fails
492///   Erdős–Gallai), or any single degree exceeds `n - 1`.
493/// * Sequence is graphical but cannot be realized as a *connected*
494///   simple graph — e.g. it contains a `0` degree on more than one
495///   vertex while the rest demand a single component.
496///
497/// # Examples
498///
499/// ```
500/// use rust_igraph::degree_sequence_game_vl;
501///
502/// // 4-cycle: every vertex degree 2 ⇒ 4 edges total, connected.
503/// let g = degree_sequence_game_vl(&[2, 2, 2, 2], 7).unwrap();
504/// assert_eq!(g.vcount(), 4);
505/// assert_eq!(g.ecount(), 4);
506/// assert!(!g.is_directed());
507/// ```
508pub fn degree_sequence_game_vl(degrees: &[u32], seed: u64) -> IgraphResult<Graph> {
509    let n_usize = degrees.len();
510    let n = u32::try_from(n_usize)
511        .map_err(|_| IgraphError::Internal("degree_sequence_game_vl: vertex count exceeds u32"))?;
512
513    if n == 0 {
514        return Graph::new(0, false);
515    }
516
517    if !is_graphical_simple_undirected(degrees) {
518        return Err(IgraphError::InvalidArgument(
519            "degree_sequence_game_vl: cannot realize the given degree sequence as an undirected, simple graph".to_string(),
520        ));
521    }
522
523    // Reject any *finite* zero-degree vertex when at least one other
524    // vertex has positive degree — upstream errors here with
525    // "Cannot make a connected graph from the given degree sequence."
526    let positive = degrees.iter().any(|&d| d > 0);
527    let any_zero = degrees.iter().any(|&d| d == 0);
528    if positive && any_zero {
529        return Err(IgraphError::InvalidArgument(
530            "degree_sequence_game_vl: cannot make a connected graph from a degree sequence containing both zero and positive degrees".to_string(),
531        ));
532    }
533
534    let sum = checked_sum_u64(degrees)?;
535    let no_of_edges_u64 = sum / 2;
536    if no_of_edges_u64 > u64::from(u32::MAX) {
537        return Err(IgraphError::Internal(
538            "degree_sequence_game_vl: edge count exceeds u32::MAX",
539        ));
540    }
541
542    // Hakimi's connected-graphical criterion: a graphical sequence with
543    // all-positive degrees is realizable as a *connected* simple graph
544    // iff Σd ≥ 2(n − 1). Reject upfront with a clear message.
545    if positive && n >= 2 && sum < 2 * u64::from(n - 1) {
546        return Err(IgraphError::InvalidArgument(
547            "degree_sequence_game_vl: degree sum is below 2·(n−1); sequence cannot be realised as a connected simple graph".to_string(),
548        ));
549    }
550
551    let mut graph = Graph::new(n, false)?;
552    if no_of_edges_u64 == 0 {
553        return Ok(graph);
554    }
555
556    let edges_init = havel_hakimi_realize(degrees, n)?;
557    let mut adj = Adj::from_edges(n, edges_init);
558    let mut rng = SplitMix64::new(seed);
559
560    make_connected(&mut adj, &mut rng)?;
561
562    // Upstream defaults: T = 5 * 2|E| total MCMC attempts, with
563    // FINAL_HEURISTICS choosing a window size in [|E|, 100·|E|]. We
564    // collapse to a single fixed window W = max(16, 2|E|) which is the
565    // mixing-time-conservative midpoint and matches the per-window
566    // behaviour of FINAL_HEURISTICS for moderate inputs.
567    let two_m: u64 = sum; // 2|E| = Σd
568    let total = two_m.saturating_mul(5);
569    let window = two_m.max(16);
570    shuffle_mcmc(&mut adj, &mut rng, total, window);
571
572    // Final defensive check: the algorithm is supposed to leave the
573    // graph connected by construction, but a non-degenerate edge-set
574    // misstep would corrupt that. Surface it.
575    if !is_connected_ignoring_isolated(&adj) {
576        return Err(IgraphError::Internal(
577            "degree_sequence_game_vl: post-shuffle graph is disconnected (please report)",
578        ));
579    }
580
581    graph.add_edges(adj.edges.iter().copied())?;
582    Ok(graph)
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588    use std::collections::HashMap;
589
590    fn observed_degrees(g: &Graph) -> Vec<u32> {
591        let vcount = g.vcount() as usize;
592        let mut deg = vec![0u32; vcount];
593        let ecount = u32::try_from(g.ecount()).expect("ecount fits u32 in tests");
594        for eid in 0..ecount {
595            let (src, dst) = g.edge(eid).expect("edge id in bounds");
596            deg[src as usize] = deg[src as usize].saturating_add(1);
597            deg[dst as usize] = deg[dst as usize].saturating_add(1);
598        }
599        deg
600    }
601
602    fn count_self_loops(g: &Graph) -> u32 {
603        let ecount = u32::try_from(g.ecount()).expect("ecount fits u32 in tests");
604        let mut loops = 0u32;
605        for eid in 0..ecount {
606            let (src, dst) = g.edge(eid).expect("edge id in bounds");
607            if src == dst {
608                loops += 1;
609            }
610        }
611        loops
612    }
613
614    fn count_multi_edges(g: &Graph) -> u32 {
615        let ecount = u32::try_from(g.ecount()).expect("ecount fits u32 in tests");
616        let mut bag: HashMap<(u32, u32), u32> = HashMap::new();
617        for eid in 0..ecount {
618            let (src, dst) = g.edge(eid).expect("edge id in bounds");
619            let key = if src <= dst { (src, dst) } else { (dst, src) };
620            *bag.entry(key).or_insert(0) += 1;
621        }
622        bag.values().map(|c| c.saturating_sub(1)).sum()
623    }
624
625    fn is_connected_simple(g: &Graph) -> bool {
626        let vcount = g.vcount() as usize;
627        if vcount == 0 {
628            return true;
629        }
630        let ecount = u32::try_from(g.ecount()).expect("ecount fits u32 in tests");
631        let mut adj: Vec<Vec<u32>> = vec![Vec::new(); vcount];
632        let mut deg = vec![0u32; vcount];
633        for eid in 0..ecount {
634            let (src, dst) = g.edge(eid).expect("edge id in bounds");
635            adj[src as usize].push(dst);
636            adj[dst as usize].push(src);
637            deg[src as usize] += 1;
638            deg[dst as usize] += 1;
639        }
640        let mut start: Option<usize> = None;
641        let mut active = 0usize;
642        for (v, &d) in deg.iter().enumerate() {
643            if d > 0 {
644                if start.is_none() {
645                    start = Some(v);
646                }
647                active += 1;
648            }
649        }
650        let Some(s) = start else {
651            return true;
652        };
653        let mut visited = vec![false; vcount];
654        let mut queue = VecDeque::new();
655        visited[s] = true;
656        queue.push_back(s);
657        let mut count = 1usize;
658        while let Some(v) = queue.pop_front() {
659            for &nbr in &adj[v] {
660                let nu = nbr as usize;
661                if !visited[nu] {
662                    visited[nu] = true;
663                    count += 1;
664                    queue.push_back(nu);
665                }
666            }
667        }
668        count == active
669    }
670
671    #[test]
672    fn empty_sequence_returns_empty_graph() {
673        let g = degree_sequence_game_vl(&[], 0).unwrap();
674        assert_eq!(g.vcount(), 0);
675        assert_eq!(g.ecount(), 0);
676        assert!(!g.is_directed());
677    }
678
679    #[test]
680    fn singleton_with_zero_degree() {
681        let g = degree_sequence_game_vl(&[0], 0).unwrap();
682        assert_eq!(g.vcount(), 1);
683        assert_eq!(g.ecount(), 0);
684    }
685
686    #[test]
687    fn all_isolated_returns_no_edges() {
688        let g = degree_sequence_game_vl(&[0, 0, 0, 0], 42).unwrap();
689        assert_eq!(g.vcount(), 4);
690        assert_eq!(g.ecount(), 0);
691    }
692
693    #[test]
694    fn cycle_4_uniform_degree_2() {
695        let g = degree_sequence_game_vl(&[2, 2, 2, 2], 1).unwrap();
696        assert_eq!(g.vcount(), 4);
697        assert_eq!(g.ecount(), 4);
698        assert_eq!(observed_degrees(&g), vec![2, 2, 2, 2]);
699        assert_eq!(count_self_loops(&g), 0);
700        assert_eq!(count_multi_edges(&g), 0);
701        assert!(is_connected_simple(&g));
702    }
703
704    #[test]
705    fn complete_graph_k4() {
706        let g = degree_sequence_game_vl(&[3, 3, 3, 3], 11).unwrap();
707        assert_eq!(g.vcount(), 4);
708        assert_eq!(g.ecount(), 6);
709        assert_eq!(observed_degrees(&g), vec![3, 3, 3, 3]);
710        assert_eq!(count_self_loops(&g), 0);
711        assert_eq!(count_multi_edges(&g), 0);
712        assert!(is_connected_simple(&g));
713    }
714
715    #[test]
716    fn path_4_endpoints_degree_one() {
717        // A path 0-1-2-3 has degrees [1,2,2,1]
718        let g = degree_sequence_game_vl(&[1, 2, 2, 1], 5).unwrap();
719        assert_eq!(g.vcount(), 4);
720        assert_eq!(g.ecount(), 3);
721        let mut deg = observed_degrees(&g);
722        deg.sort_unstable();
723        assert_eq!(deg, vec![1, 1, 2, 2]);
724        assert_eq!(count_self_loops(&g), 0);
725        assert_eq!(count_multi_edges(&g), 0);
726        assert!(is_connected_simple(&g));
727    }
728
729    #[test]
730    fn star_k1_n_realises_exact_degrees() {
731        // Star on 6 vertices: hub degree 5, leaves degree 1.
732        let seq = vec![5u32, 1, 1, 1, 1, 1];
733        let g = degree_sequence_game_vl(&seq, 99).unwrap();
734        assert_eq!(g.vcount(), 6);
735        assert_eq!(g.ecount(), 5);
736        let mut deg = observed_degrees(&g);
737        deg.sort_unstable();
738        assert_eq!(deg, vec![1, 1, 1, 1, 1, 5]);
739        assert!(is_connected_simple(&g));
740    }
741
742    #[test]
743    fn powerlaw_like_sequence_preserves_degrees() {
744        // Skewed-but-graphical sequence on n=10 (sum=30, passes E-G).
745        let seq: Vec<u32> = vec![5, 4, 4, 3, 3, 3, 2, 2, 2, 2];
746        let g = degree_sequence_game_vl(&seq, 0xABCD_1234).unwrap();
747        assert_eq!(g.vcount(), 10);
748        assert_eq!(observed_degrees(&g), seq);
749        assert_eq!(count_self_loops(&g), 0);
750        assert_eq!(count_multi_edges(&g), 0);
751        assert!(is_connected_simple(&g));
752    }
753
754    #[test]
755    fn larger_uniform_3_regular_n10() {
756        let seq: Vec<u32> = vec![3; 10];
757        let g = degree_sequence_game_vl(&seq, 0xDEAD_BEEF).unwrap();
758        assert_eq!(g.vcount(), 10);
759        assert_eq!(g.ecount(), 15);
760        assert_eq!(observed_degrees(&g), seq);
761        assert_eq!(count_self_loops(&g), 0);
762        assert_eq!(count_multi_edges(&g), 0);
763        assert!(is_connected_simple(&g));
764    }
765
766    #[test]
767    fn odd_degree_sum_rejected() {
768        let r = degree_sequence_game_vl(&[1, 1, 1], 0);
769        assert!(matches!(r, Err(IgraphError::InvalidArgument(_))));
770    }
771
772    #[test]
773    fn degree_exceeds_n_minus_1_rejected() {
774        // 4 vertices, request degree 4 on one: impossible (max is 3).
775        let r = degree_sequence_game_vl(&[4, 1, 1, 1], 0);
776        assert!(matches!(r, Err(IgraphError::InvalidArgument(_))));
777    }
778
779    #[test]
780    fn mixed_zero_with_positive_rejected() {
781        // Cannot realize as connected: one vertex must be isolated.
782        let r = degree_sequence_game_vl(&[2, 2, 2, 0], 0);
783        assert!(matches!(r, Err(IgraphError::InvalidArgument(_))));
784    }
785
786    #[test]
787    fn non_graphical_erdos_gallai_rejected() {
788        // [3, 3, 1, 1] sums to 8 (even) and max=3=n-1 but fails E-G:
789        // sort desc = [3,3,1,1]; k=2: 3+3=6, RHS = 2·1 + min(1,2)+min(1,2)
790        //   = 2 + 2 = 4. 6 > 4 ⇒ not graphical.
791        let r = degree_sequence_game_vl(&[3, 3, 1, 1], 0);
792        assert!(matches!(r, Err(IgraphError::InvalidArgument(_))));
793    }
794
795    #[test]
796    fn determinism_same_seed_yields_same_graph() {
797        let seq = vec![3u32; 10];
798        let g1 = degree_sequence_game_vl(&seq, 0xCAFE_BABE).unwrap();
799        let g2 = degree_sequence_game_vl(&seq, 0xCAFE_BABE).unwrap();
800        assert_eq!(g1.ecount(), g2.ecount());
801        let ecount = u32::try_from(g1.ecount()).unwrap();
802        let mut e1: Vec<(u32, u32)> = (0..ecount).map(|i| g1.edge(i).unwrap()).collect();
803        let mut e2: Vec<(u32, u32)> = (0..ecount).map(|i| g2.edge(i).unwrap()).collect();
804        e1.sort_unstable();
805        e2.sort_unstable();
806        assert_eq!(e1, e2);
807    }
808
809    #[test]
810    fn different_seeds_can_yield_different_graphs() {
811        let seq = vec![3u32; 10];
812        let g1 = degree_sequence_game_vl(&seq, 1).unwrap();
813        let g2 = degree_sequence_game_vl(&seq, 2).unwrap();
814        let ecount = u32::try_from(g1.ecount()).unwrap();
815        let mut e1: Vec<(u32, u32)> = (0..ecount).map(|i| g1.edge(i).unwrap()).collect();
816        let mut e2: Vec<(u32, u32)> = (0..ecount).map(|i| g2.edge(i).unwrap()).collect();
817        e1.sort_unstable();
818        e2.sort_unstable();
819        // The two graphs need not differ in the worst case, but for
820        // n=10 d=3 the configuration space is large enough that they
821        // almost surely do.
822        assert!(e1 != e2 || g1.ecount() < 5);
823    }
824
825    #[test]
826    fn full_connected_sweep_n6_3regular() {
827        // Repeatedly sample and check invariants across seeds.
828        let seq = vec![3u32; 6];
829        for seed in 0..20u64 {
830            let g = degree_sequence_game_vl(&seq, seed).unwrap();
831            assert_eq!(g.vcount(), 6, "seed={seed}");
832            assert_eq!(g.ecount(), 9, "seed={seed}");
833            assert_eq!(observed_degrees(&g), seq, "seed={seed}");
834            assert_eq!(count_self_loops(&g), 0, "seed={seed}");
835            assert_eq!(count_multi_edges(&g), 0, "seed={seed}");
836            assert!(is_connected_simple(&g), "seed={seed}");
837        }
838    }
839
840    #[test]
841    fn invariants_hold_for_skewed_n12() {
842        let seq: Vec<u32> = vec![5, 4, 4, 3, 3, 3, 2, 2, 2, 2, 1, 1];
843        let g = degree_sequence_game_vl(&seq, 0xC001_D00D).unwrap();
844        assert_eq!(g.vcount(), 12);
845        assert_eq!(g.ecount(), 16);
846        let mut got = observed_degrees(&g);
847        let mut want = seq.clone();
848        got.sort_unstable();
849        want.sort_unstable();
850        assert_eq!(got, want);
851        assert_eq!(count_self_loops(&g), 0);
852        assert_eq!(count_multi_edges(&g), 0);
853        assert!(is_connected_simple(&g));
854    }
855
856    #[cfg(all(test, feature = "proptest-harness"))]
857    mod proptests {
858        use super::*;
859        use proptest::prelude::*;
860
861        fn gen_graphical_seq() -> impl Strategy<Value = Vec<u32>> {
862            // Pick n in [3,12], generate raw degrees in [1, n-1], then
863            // adjust the parity by bumping the first entry.
864            (3usize..=12).prop_flat_map(|n| {
865                let max_d = (n - 1) as u32;
866                prop::collection::vec(1u32..=max_d, n).prop_map(move |mut v| {
867                    let sum: u64 = v.iter().map(|&d| u64::from(d)).sum();
868                    if sum % 2 != 0 {
869                        if v[0] < max_d {
870                            v[0] += 1;
871                        } else {
872                            v[0] -= 1;
873                        }
874                    }
875                    v
876                })
877            })
878        }
879
880        proptest! {
881            #[test]
882            fn proptest_invariants_on_graphical_seqs(
883                seq in gen_graphical_seq(),
884                seed in any::<u64>(),
885            ) {
886                // Skip seeds where the random sequence happens not to
887                // be graphical — the function should error cleanly.
888                let result = degree_sequence_game_vl(&seq, seed);
889                match result {
890                    Ok(g) => {
891                        prop_assert_eq!(g.vcount() as usize, seq.len());
892                        let sum: u64 = seq.iter().map(|&d| u64::from(d)).sum();
893                        prop_assert_eq!(g.ecount(), (sum / 2) as usize);
894                        let mut got = observed_degrees(&g);
895                        let mut want = seq.clone();
896                        got.sort_unstable();
897                        want.sort_unstable();
898                        prop_assert_eq!(got, want);
899                        prop_assert_eq!(count_self_loops(&g), 0);
900                        prop_assert_eq!(count_multi_edges(&g), 0);
901                        prop_assert!(is_connected_simple(&g));
902                    }
903                    Err(_) => {
904                        // Acceptable rejections: non-graphical (E-G),
905                        // mixed zero/positive, or graphical but Σd <
906                        // 2(n-1) (no connected realisation per Hakimi).
907                        let n = seq.len() as u64;
908                        let sum: u64 = seq.iter().map(|&d| u64::from(d)).sum();
909                        let has_zero = seq.iter().any(|&d| d == 0);
910                        let has_pos = seq.iter().any(|&d| d > 0);
911                        let below_hakimi = has_pos && n >= 2 && sum < 2 * (n - 1);
912                        prop_assert!(
913                            !is_graphical_simple_undirected(&seq)
914                                || (has_zero && has_pos)
915                                || below_hakimi,
916                            "unexpected error for seq = {:?}, sum = {}, n = {}",
917                            seq, sum, n
918                        );
919                    }
920                }
921            }
922
923            #[test]
924            fn proptest_determinism_same_seed(
925                seq in gen_graphical_seq(),
926                seed in any::<u64>(),
927            ) {
928                let g1 = degree_sequence_game_vl(&seq, seed);
929                let g2 = degree_sequence_game_vl(&seq, seed);
930                match (g1, g2) {
931                    (Ok(g1), Ok(g2)) => {
932                        prop_assert_eq!(g1.ecount(), g2.ecount());
933                        let ecount = u32::try_from(g1.ecount()).unwrap();
934                        let mut e1: Vec<(u32, u32)> =
935                            (0..ecount).map(|i| g1.edge(i).unwrap()).collect();
936                        let mut e2: Vec<(u32, u32)> =
937                            (0..ecount).map(|i| g2.edge(i).unwrap()).collect();
938                        e1.sort_unstable();
939                        e2.sort_unstable();
940                        prop_assert_eq!(e1, e2);
941                    }
942                    (Err(_), Err(_)) => {}
943                    _ => prop_assert!(false, "outcome differs between identical calls"),
944                }
945            }
946        }
947    }
948}