Skip to main content

rust_igraph/algorithms/games/
degree_sequence.rs

1//! Configuration-model degree-sequence generator (ALGO-GN-024).
2//!
3//! Counterpart of the `IGRAPH_DEGSEQ_CONFIGURATION` branch of
4//! `igraph_degree_sequence_game()` in
5//! `references/igraph/src/games/degree_sequence.c` (function
6//! `configuration`, lines 37-123).
7//!
8//! The configuration model (Bender–Canfield 1978 / Bollobás 1980) builds
9//! a random graph that realises a given degree sequence by
10//! **stub matching**:
11//!
12//! 1. Expand each vertex `i` into `d_i` half-edge "stubs" — a flat bag of
13//!    `Σ d_i` stubs.
14//! 2. Repeatedly pick two stubs uniformly at random and pair them into an
15//!    edge.
16//! 3. Project the matched stubs to a graph by reading off vertex labels.
17//!
18//! The result is a **multigraph** — self-loops and multi-edges are
19//! allowed and occur with positive probability whenever the degree
20//! sequence permits them. To sample uniformly from *simple* graphs with a
21//! given degree sequence, use the Viger–Latapy (`VL`) or
22//! `EDGE_SWITCHING_SIMPLE` methods instead (planned for follow-up AWUs
23//! GN-025+).
24//!
25//! ## Directed vs undirected
26//!
27//! * **Undirected** (`in_degrees = None`): single bag of size
28//!   `outsum = Σ out_degrees`. Each iteration pops two stubs (with
29//!   replacement-via-swap-pop) and emits a single edge. Requires
30//!   `outsum` to be **even** so the bag fully drains.
31//! * **Directed** (`in_degrees = Some(in_seq)`): two bags. Out-stubs are
32//!   the edge sources, in-stubs the sinks. Requires
33//!   `Σ out_degrees == Σ in_degrees`.
34//!
35//! ## Determinism
36//!
37//! All randomness flows from a single `SplitMix64` seed — the same
38//! `(out_degrees, in_degrees, seed)` triple always yields the same graph.
39//! The PRNG state is not bitwise portable to igraph C / `NumPy` / R, so
40//! the three-source conformance harness asserts structural invariants
41//! only (vcount, ecount, exact degree match, directed-ness).
42//!
43//! ## Complexity
44//!
45//! Stub-bag construction: `Θ(n + Σ d_i)`. Edge sampling: `Θ(|E|)` with
46//! `O(1)` random-access pops via Fisher-Yates–style swap-removal.
47//! Total: `Θ(n + |E|)` time and `Θ(|E|)` peak memory for the bags.
48
49#![allow(clippy::cast_possible_truncation)]
50
51use crate::core::rng::SplitMix64;
52use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
53
54/// Sum a slice of `u32` degrees into `u64` with overflow checking.
55fn checked_sum(degrees: &[u32]) -> IgraphResult<u64> {
56    let mut acc: u64 = 0;
57    for &d in degrees {
58        acc = acc
59            .checked_add(u64::from(d))
60            .ok_or(IgraphError::Internal("degree-sum overflow"))?;
61    }
62    Ok(acc)
63}
64
65/// Expand a degree slice into a flat stub bag where vertex `i` appears
66/// exactly `degrees[i]` times.
67fn build_bag(degrees: &[u32], total: u64) -> IgraphResult<Vec<VertexId>> {
68    let cap = usize::try_from(total)
69        .map_err(|_| IgraphError::Internal("degree-sequence stub count exceeds usize"))?;
70    let mut bag: Vec<VertexId> = Vec::with_capacity(cap);
71    for (i, &d) in degrees.iter().enumerate() {
72        let vid = VertexId::try_from(i)
73            .map_err(|_| IgraphError::Internal("degree-sequence vertex index exceeds u32"))?;
74        for _ in 0..d {
75            bag.push(vid);
76        }
77    }
78    Ok(bag)
79}
80
81/// Sample a random graph realising the given degree sequence(s) via the
82/// configuration / stub-matching model.
83///
84/// * `out_degrees` — degree of every vertex (undirected) or out-degree
85///   of every vertex (directed). Length defines vertex count `n`.
86/// * `in_degrees` — when `Some(seq)`, switches to directed mode and
87///   `seq[i]` is the in-degree of vertex `i`. Must have the same length
88///   as `out_degrees` and the same total.
89/// * `seed` — drives the internal `SplitMix64` PRNG.
90///
91/// The output is a **multigraph**: self-loops and parallel edges can
92/// occur whenever the degree sequence allows them, with the natural
93/// configuration-model probabilities.
94///
95/// # Errors
96///
97/// * `in_degrees` length disagrees with `out_degrees` length.
98/// * Degree sum overflows `u64` (only possible at billion-vertex scale).
99/// * Undirected mode with an odd `Σ out_degrees` (not graphical).
100/// * Directed mode with `Σ out_degrees != Σ in_degrees`.
101/// * Edge count overflows `u32` (igraph's hard `ECOUNT_MAX`).
102/// * Vertex count exceeds `u32::MAX`.
103///
104/// # Examples
105///
106/// ```
107/// use rust_igraph::degree_sequence_game_configuration;
108///
109/// // Undirected 4-cycle target: every vertex degree 2 ⇒ 4 edges total.
110/// let g = degree_sequence_game_configuration(&[2, 2, 2, 2], None, 7).unwrap();
111/// assert_eq!(g.vcount(), 4);
112/// assert_eq!(g.ecount(), 4);
113/// assert!(!g.is_directed());
114/// ```
115pub fn degree_sequence_game_configuration(
116    out_degrees: &[u32],
117    in_degrees: Option<&[u32]>,
118    seed: u64,
119) -> IgraphResult<Graph> {
120    let directed = in_degrees.is_some();
121    let n_usize = out_degrees.len();
122    let n = u32::try_from(n_usize)
123        .map_err(|_| IgraphError::Internal("degree-sequence vertex count exceeds u32"))?;
124
125    if let Some(in_seq) = in_degrees {
126        if in_seq.len() != n_usize {
127            return Err(IgraphError::InvalidArgument(format!(
128                "degree_sequence_game_configuration: out_degrees has length {} but in_degrees has length {}",
129                n_usize,
130                in_seq.len()
131            )));
132        }
133    }
134
135    if n == 0 {
136        return Graph::new(0, directed);
137    }
138
139    let outsum = checked_sum(out_degrees)?;
140    let (insum, no_of_edges) = if let Some(in_seq) = in_degrees {
141        let insum = checked_sum(in_seq)?;
142        if outsum != insum {
143            return Err(IgraphError::InvalidArgument(format!(
144                "degree_sequence_game_configuration: out-degree sum {outsum} != in-degree sum {insum} (no directed graph realises the given sequences)"
145            )));
146        }
147        (insum, outsum)
148    } else {
149        if outsum % 2 != 0 {
150            return Err(IgraphError::InvalidArgument(format!(
151                "degree_sequence_game_configuration: undirected degree sum {outsum} is odd (no graph realises an odd-sum degree sequence)"
152            )));
153        }
154        (0u64, outsum / 2)
155    };
156
157    if no_of_edges > u64::from(u32::MAX) {
158        return Err(IgraphError::Internal(
159            "degree_sequence_game_configuration: edge count exceeds u32::MAX",
160        ));
161    }
162    let no_of_edges_usize = no_of_edges as usize;
163
164    let mut bag1 = build_bag(out_degrees, outsum)?;
165    let mut bag2_opt: Option<Vec<VertexId>> = if let Some(in_seq) = in_degrees {
166        Some(build_bag(in_seq, insum)?)
167    } else {
168        None
169    };
170
171    let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(no_of_edges_usize);
172    let mut rng = SplitMix64::new(seed);
173
174    if let Some(bag2) = bag2_opt.as_mut() {
175        for _ in 0..no_of_edges_usize {
176            let from_idx = rng.gen_index(bag1.len());
177            let to_idx = rng.gen_index(bag2.len());
178            let from = bag1.swap_remove(from_idx);
179            let to = bag2.swap_remove(to_idx);
180            edges.push((from, to));
181        }
182    } else {
183        for _ in 0..no_of_edges_usize {
184            let from_idx = rng.gen_index(bag1.len());
185            let from = bag1.swap_remove(from_idx);
186            // Bag has at least one element left because outsum is even.
187            let to_idx = rng.gen_index(bag1.len());
188            let to = bag1.swap_remove(to_idx);
189            edges.push((from, to));
190        }
191    }
192
193    let mut g = Graph::new(n, directed)?;
194    g.add_edges(edges)?;
195    Ok(g)
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    fn observed_undirected_degrees(graph: &Graph) -> Vec<u32> {
203        let vcount = graph.vcount() as usize;
204        let mut deg = vec![0u32; vcount];
205        let ecount = u32::try_from(graph.ecount()).expect("test ecount fits u32");
206        for eid in 0..ecount {
207            let (src, dst) = graph.edge(eid).expect("test edge id in bounds");
208            deg[src as usize] = deg[src as usize].saturating_add(1);
209            deg[dst as usize] = deg[dst as usize].saturating_add(1);
210        }
211        deg
212    }
213
214    fn observed_directed_degrees(graph: &Graph) -> (Vec<u32>, Vec<u32>) {
215        let vcount = graph.vcount() as usize;
216        let mut outd = vec![0u32; vcount];
217        let mut ind = vec![0u32; vcount];
218        let ecount = u32::try_from(graph.ecount()).expect("test ecount fits u32");
219        for eid in 0..ecount {
220            let (src, dst) = graph.edge(eid).expect("test edge id in bounds");
221            outd[src as usize] = outd[src as usize].saturating_add(1);
222            ind[dst as usize] = ind[dst as usize].saturating_add(1);
223        }
224        (outd, ind)
225    }
226
227    #[test]
228    fn empty_degree_sequence_returns_empty_graph() {
229        let g = degree_sequence_game_configuration(&[], None, 0).unwrap();
230        assert_eq!(g.vcount(), 0);
231        assert_eq!(g.ecount(), 0);
232        assert!(!g.is_directed());
233    }
234
235    #[test]
236    fn empty_directed_degree_sequence_returns_empty_graph() {
237        let g = degree_sequence_game_configuration(&[], Some(&[]), 0).unwrap();
238        assert_eq!(g.vcount(), 0);
239        assert_eq!(g.ecount(), 0);
240        assert!(g.is_directed());
241    }
242
243    #[test]
244    fn all_zero_undirected_produces_edgeless_graph() {
245        let g = degree_sequence_game_configuration(&[0, 0, 0, 0, 0], None, 1).unwrap();
246        assert_eq!(g.vcount(), 5);
247        assert_eq!(g.ecount(), 0);
248    }
249
250    #[test]
251    fn all_zero_directed_produces_edgeless_graph() {
252        let g = degree_sequence_game_configuration(&[0, 0, 0], Some(&[0, 0, 0]), 1).unwrap();
253        assert_eq!(g.vcount(), 3);
254        assert_eq!(g.ecount(), 0);
255    }
256
257    #[test]
258    fn observed_undirected_degrees_match_input() {
259        let seq = [2, 3, 2, 3, 3, 3, 3, 1, 4, 4];
260        let g = degree_sequence_game_configuration(&seq, None, 333).unwrap();
261        assert_eq!(g.vcount(), seq.len() as u32);
262        let observed = observed_undirected_degrees(&g);
263        assert_eq!(observed, seq);
264    }
265
266    #[test]
267    fn observed_directed_degrees_match_input() {
268        let out_seq = [2, 3, 2, 3, 3, 3, 3, 1, 4, 4];
269        let in_seq = [3, 6, 2, 0, 2, 2, 4, 3, 3, 3];
270        let g = degree_sequence_game_configuration(&out_seq, Some(&in_seq), 333).unwrap();
271        assert_eq!(g.vcount(), out_seq.len() as u32);
272        let (outd, ind) = observed_directed_degrees(&g);
273        assert_eq!(outd, out_seq);
274        assert_eq!(ind, in_seq);
275    }
276
277    #[test]
278    fn ecount_equals_half_undirected_sum() {
279        let seq = [4u32, 4, 4, 4, 4, 4, 4, 4]; // sum = 32, expect 16 edges
280        let g = degree_sequence_game_configuration(&seq, None, 17).unwrap();
281        assert_eq!(g.ecount(), 16);
282    }
283
284    #[test]
285    fn ecount_equals_outsum_directed() {
286        let out_seq = [2u32, 1, 3];
287        let in_seq = [1u32, 3, 2];
288        let g = degree_sequence_game_configuration(&out_seq, Some(&in_seq), 11).unwrap();
289        assert_eq!(g.ecount(), 6);
290    }
291
292    #[test]
293    fn rejects_length_mismatch() {
294        let err = degree_sequence_game_configuration(&[1, 2, 3], Some(&[1, 2]), 1).unwrap_err();
295        match err {
296            IgraphError::InvalidArgument(msg) => assert!(msg.contains("length")),
297            other => panic!("expected InvalidArgument, got {other:?}"),
298        }
299    }
300
301    #[test]
302    fn rejects_odd_undirected_sum() {
303        let err = degree_sequence_game_configuration(&[1, 1, 1], None, 1).unwrap_err();
304        match err {
305            IgraphError::InvalidArgument(msg) => assert!(msg.contains("odd")),
306            other => panic!("expected InvalidArgument, got {other:?}"),
307        }
308    }
309
310    #[test]
311    fn rejects_directed_sum_mismatch() {
312        let err = degree_sequence_game_configuration(&[1, 2, 3], Some(&[1, 1, 1]), 1).unwrap_err();
313        match err {
314            IgraphError::InvalidArgument(msg) => {
315                assert!(msg.contains("out-degree sum") && msg.contains("in-degree sum"));
316            }
317            other => panic!("expected InvalidArgument, got {other:?}"),
318        }
319    }
320
321    #[test]
322    fn deterministic_same_seed_same_edges() {
323        let seq = [3u32, 3, 3, 3, 3, 3];
324        let g1 = degree_sequence_game_configuration(&seq, None, 42).unwrap();
325        let g2 = degree_sequence_game_configuration(&seq, None, 42).unwrap();
326        let collect = |g: &Graph| -> Vec<(VertexId, VertexId)> {
327            let m = u32::try_from(g.ecount()).unwrap();
328            let mut v: Vec<_> = (0..m)
329                .map(|e| {
330                    let (u, w) = g.edge(e).unwrap();
331                    if u <= w { (u, w) } else { (w, u) }
332                })
333                .collect();
334            v.sort_unstable();
335            v
336        };
337        assert_eq!(collect(&g1), collect(&g2));
338    }
339
340    #[test]
341    fn distinct_seeds_usually_differ() {
342        let seq = [4u32, 4, 4, 4, 4, 4, 4, 4];
343        let g1 = degree_sequence_game_configuration(&seq, None, 1).unwrap();
344        let g2 = degree_sequence_game_configuration(&seq, None, 2).unwrap();
345        let collect = |g: &Graph| -> Vec<(VertexId, VertexId)> {
346            let m = u32::try_from(g.ecount()).unwrap();
347            let mut v: Vec<_> = (0..m)
348                .map(|e| {
349                    let (u, w) = g.edge(e).unwrap();
350                    if u <= w { (u, w) } else { (w, u) }
351                })
352                .collect();
353            v.sort_unstable();
354            v
355        };
356        assert_ne!(collect(&g1), collect(&g2));
357    }
358
359    #[test]
360    fn single_vertex_with_self_loop_via_d2() {
361        // Only realisation of [2] is one self-loop.
362        let g = degree_sequence_game_configuration(&[2], None, 1).unwrap();
363        assert_eq!(g.vcount(), 1);
364        assert_eq!(g.ecount(), 1);
365        let (u, v) = g.edge(0).unwrap();
366        assert_eq!((u, v), (0, 0));
367    }
368
369    #[test]
370    fn directed_single_vertex_self_loop() {
371        // d_out=[1], d_in=[1] ⇒ one self-loop.
372        let g = degree_sequence_game_configuration(&[1], Some(&[1]), 1).unwrap();
373        assert_eq!(g.vcount(), 1);
374        assert_eq!(g.ecount(), 1);
375        let (u, v) = g.edge(0).unwrap();
376        assert_eq!((u, v), (0, 0));
377    }
378
379    #[test]
380    fn directed_path_realisation() {
381        // d_out=[1,1,0], d_in=[0,1,1] — the only realisation is 0→1, 1→2.
382        let g = degree_sequence_game_configuration(&[1, 1, 0], Some(&[0, 1, 1]), 1).unwrap();
383        assert_eq!(g.vcount(), 3);
384        assert_eq!(g.ecount(), 2);
385        let edges: Vec<_> = (0..2).map(|e| g.edge(e).unwrap()).collect();
386        let mut got = edges.clone();
387        got.sort_unstable();
388        assert_eq!(got, vec![(0, 1), (1, 2)]);
389    }
390
391    #[test]
392    fn determinism_across_sweep() {
393        // Run a sweep of (n, seed) pairs and confirm reproducibility.
394        for n in [3u32, 8, 20, 50] {
395            let seq: Vec<u32> = (0..n).map(|i| 2 + (i % 3)).collect();
396            // Ensure even sum.
397            let mut seq = seq;
398            let s: u32 = seq.iter().sum();
399            if s % 2 != 0 {
400                seq[0] += 1;
401            }
402            for seed in [0u64, 1, 7, 1_234_567] {
403                let g1 = degree_sequence_game_configuration(&seq, None, seed).unwrap();
404                let g2 = degree_sequence_game_configuration(&seq, None, seed).unwrap();
405                assert_eq!(g1.vcount(), g2.vcount());
406                assert_eq!(g1.ecount(), g2.ecount());
407                assert_eq!(
408                    observed_undirected_degrees(&g1),
409                    observed_undirected_degrees(&g2)
410                );
411            }
412        }
413    }
414
415    #[test]
416    fn all_ones_undirected_is_perfect_matching_size() {
417        let seq = [1u32; 10];
418        let g = degree_sequence_game_configuration(&seq, None, 99).unwrap();
419        assert_eq!(g.ecount(), 5);
420        let observed = observed_undirected_degrees(&g);
421        assert_eq!(observed, seq);
422    }
423
424    #[test]
425    fn large_uniform_directed_passes_degree_check() {
426        let n = 100u32;
427        let d = 5u32;
428        let out_seq = vec![d; n as usize];
429        let in_seq = vec![d; n as usize];
430        let g = degree_sequence_game_configuration(&out_seq, Some(&in_seq), 0xABCD).unwrap();
431        assert_eq!(g.vcount(), n);
432        assert_eq!(g.ecount(), (n * d) as usize);
433        let (outd, ind) = observed_directed_degrees(&g);
434        assert_eq!(outd, out_seq);
435        assert_eq!(ind, in_seq);
436    }
437
438    #[test]
439    fn directed_zero_in_zero_out_disconnected_vertex_allowed() {
440        // Vertex 2 has degree 0 in both directions — must remain isolated.
441        let out_seq = [1u32, 1, 0];
442        let in_seq = [1u32, 1, 0];
443        let g = degree_sequence_game_configuration(&out_seq, Some(&in_seq), 0).unwrap();
444        let (outd, ind) = observed_directed_degrees(&g);
445        assert_eq!(outd, out_seq);
446        assert_eq!(ind, in_seq);
447    }
448
449    #[test]
450    fn rejects_directed_with_out_zero_in_nonzero() {
451        // Sum mismatch: 0 vs 2.
452        let err = degree_sequence_game_configuration(&[0, 0], Some(&[1, 1]), 1).unwrap_err();
453        match err {
454            IgraphError::InvalidArgument(_) => {}
455            other => panic!("expected InvalidArgument, got {other:?}"),
456        }
457    }
458}
459
460#[cfg(all(test, feature = "proptest-harness"))]
461mod proptests {
462    use super::*;
463    use proptest::prelude::*;
464
465    fn even_sum_seq() -> impl Strategy<Value = Vec<u32>> {
466        prop::collection::vec(0u32..6, 0..15).prop_map(|mut v| {
467            let s: u32 = v.iter().sum();
468            if s % 2 != 0 && !v.is_empty() {
469                v[0] += 1;
470            }
471            v
472        })
473    }
474
475    fn matched_directed_seqs() -> impl Strategy<Value = (Vec<u32>, Vec<u32>)> {
476        prop::collection::vec((0u32..5, 0u32..5), 0..12).prop_map(|pairs| {
477            let mut out: Vec<u32> = pairs.iter().map(|p| p.0).collect();
478            let mut inn: Vec<u32> = pairs.iter().map(|p| p.1).collect();
479            let os: u64 = out.iter().map(|&d| u64::from(d)).sum();
480            let is: u64 = inn.iter().map(|&d| u64::from(d)).sum();
481            if !out.is_empty() {
482                if os > is {
483                    inn[0] = inn[0].saturating_add(u32::try_from(os - is).unwrap_or(0));
484                } else if is > os {
485                    out[0] = out[0].saturating_add(u32::try_from(is - os).unwrap_or(0));
486                }
487            }
488            (out, inn)
489        })
490    }
491
492    proptest! {
493        #![proptest_config(ProptestConfig::with_cases(64))]
494
495        #[test]
496        fn undirected_degree_match(seq in even_sum_seq(), seed in any::<u64>()) {
497            let g = degree_sequence_game_configuration(&seq, None, seed)
498                .expect("valid input must succeed");
499            prop_assert_eq!(g.vcount(), seq.len() as u32);
500            let sum: u64 = seq.iter().map(|&d| u64::from(d)).sum();
501            prop_assert_eq!(g.ecount() as u64, sum / 2);
502            let n = u32::try_from(g.vcount()).unwrap();
503            let mut deg = vec![0u32; n as usize];
504            let m = u32::try_from(g.ecount()).unwrap();
505            for eid in 0..m {
506                let (u, v) = g.edge(eid).unwrap();
507                deg[u as usize] += 1;
508                deg[v as usize] += 1;
509            }
510            prop_assert_eq!(deg, seq);
511        }
512
513        #[test]
514        fn directed_degree_match((out_seq, in_seq) in matched_directed_seqs(), seed in any::<u64>()) {
515            let g = degree_sequence_game_configuration(&out_seq, Some(&in_seq), seed)
516                .expect("valid input must succeed");
517            prop_assert_eq!(g.vcount(), out_seq.len() as u32);
518            let n = u32::try_from(g.vcount()).unwrap();
519            let mut outd = vec![0u32; n as usize];
520            let mut ind = vec![0u32; n as usize];
521            let m = u32::try_from(g.ecount()).unwrap();
522            for eid in 0..m {
523                let (u, v) = g.edge(eid).unwrap();
524                outd[u as usize] += 1;
525                ind[v as usize] += 1;
526            }
527            prop_assert_eq!(outd, out_seq);
528            prop_assert_eq!(ind, in_seq);
529        }
530
531        #[test]
532        fn deterministic_same_seed(seq in even_sum_seq(), seed in any::<u64>()) {
533            let g1 = degree_sequence_game_configuration(&seq, None, seed)
534                .expect("valid input must succeed");
535            let g2 = degree_sequence_game_configuration(&seq, None, seed)
536                .expect("valid input must succeed");
537            let collect = |g: &Graph| -> Vec<(VertexId, VertexId)> {
538                let m = u32::try_from(g.ecount()).unwrap();
539                let mut v: Vec<_> = (0..m).map(|e| g.edge(e).unwrap()).collect();
540                v.sort_unstable();
541                v
542            };
543            prop_assert_eq!(collect(&g1), collect(&g2));
544        }
545
546        #[test]
547        fn odd_sum_rejected(seq in prop::collection::vec(0u32..6, 1..15)) {
548            let s: u32 = seq.iter().sum();
549            prop_assume!(s % 2 != 0);
550            let r = degree_sequence_game_configuration(&seq, None, 0);
551            prop_assert!(r.is_err());
552        }
553    }
554}