Skip to main content

rust_igraph/algorithms/games/
k_regular.rs

1//! k-regular random graph generator (ALGO-GN-008).
2//!
3//! Counterpart of `igraph_k_regular_game()` in
4//! `references/igraph/src/games/k_regular.c:57-83`, which itself is a
5//! thin wrapper over `igraph_degree_sequence_game()` (see
6//! `references/igraph/src/games/degree_sequence.c`). We self-roll the
7//! two methods used by the wrapper rather than port the full
8//! degree-sequence machinery (~870 C lines covering eight sampling
9//! modes); that keeps the dependency footprint focused on what
10//! k-regular actually needs.
11//!
12//! ## Model
13//!
14//! Every vertex of the returned graph has degree exactly `k`. In the
15//! directed case both the in- and out-degree are `k`.
16//!
17//! Two sampling regimes are exposed via the `multiple` flag, mirroring
18//! the upstream split:
19//!
20//! * `multiple = true` — **configuration model**. Build `n·k` stubs
21//!   (one per half-edge), pair them uniformly at random *without
22//!   replacement*. Self-loops and parallel edges are allowed. Runtime
23//!   `O(n + |E|)`.
24//! * `multiple = false` — **fast-heuristic simple**. Same stub bag, but
25//!   reject any pair that would form a self-loop or duplicate an
26//!   existing edge; failed stubs are queued into the next sweep over
27//!   the residual degree sequence; if a sweep ends with no feasible
28//!   pair the whole attempt is restarted from scratch. This is the
29//!   sampling scheme `IGRAPH_DEGSEQ_FAST_HEUR_SIMPLE` uses, and like
30//!   the C reference it does **not** sample uniformly from the space
31//!   of all simple k-regular graphs — every realisable graph appears
32//!   with positive probability but not necessarily equal probability.
33//!
34//! ## Validation
35//!
36//! * `k = 0` always succeeds and returns an edgeless graph.
37//! * Undirected simple: requires `k ≤ n - 1` and `n·k` even (handshake).
38//! * Undirected multigraph: requires `n·k` even (handshake).
39//! * Directed simple: requires `k ≤ n - 1`. The handshake parity
40//!   constraint is automatically satisfied because in- and out-degrees
41//!   match by construction.
42//! * Directed multigraph: no degree-bound constraint, no parity
43//!   constraint.
44//! * `n·k` must fit in `u32` (otherwise the stub vector overflows).
45//!
46//! ## Determinism
47//!
48//! Fully deterministic in `(n, k, directed, multiple, seed)` via
49//! `SplitMix64`.
50
51#![allow(
52    clippy::cast_possible_truncation,
53    clippy::cast_sign_loss,
54    clippy::many_single_char_names
55)]
56
57use std::collections::HashSet;
58
59use crate::core::rng::SplitMix64;
60use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
61
62/// Hard cap on outer restarts of the simple-graph fast-heur loop.
63///
64/// In practice every realisable `(n, k)` with `k ≪ n − 1` succeeds on
65/// the first attempt; the cap exists only so an adversarial input
66/// (e.g. `k = n − 2` where the simple realisation is essentially
67/// unique) cannot lock the caller into an unbounded retry loop.
68const FAST_HEUR_MAX_RESTARTS: u32 = 1024;
69
70fn validate(n: u32, k: u32, directed: bool, multiple: bool) -> IgraphResult<()> {
71    let total = u64::from(n) * u64::from(k);
72    if total > u64::from(u32::MAX) {
73        return Err(IgraphError::InvalidArgument(format!(
74            "k_regular_game: n * k ({total}) overflows u32"
75        )));
76    }
77    if !directed && !multiple && k % 2 != 0 && n % 2 != 0 {
78        return Err(IgraphError::InvalidArgument(format!(
79            "k_regular_game: undirected simple requires n*k even (got n={n}, k={k})"
80        )));
81    }
82    if !directed && multiple && total % 2 != 0 {
83        return Err(IgraphError::InvalidArgument(format!(
84            "k_regular_game: undirected multigraph requires n*k even (got n={n}, k={k})"
85        )));
86    }
87    if !multiple && k > 0 && k > n.saturating_sub(1) {
88        return Err(IgraphError::InvalidArgument(format!(
89            "k_regular_game: simple graph requires k <= n - 1 (got n={n}, k={k})"
90        )));
91    }
92    Ok(())
93}
94
95fn fisher_yates_shuffle<T>(slice: &mut [T], rng: &mut SplitMix64) {
96    let len = slice.len();
97    if len < 2 {
98        return;
99    }
100    for i in (1..len).rev() {
101        let j = rng.gen_index(i + 1);
102        slice.swap(i, j);
103    }
104}
105
106/// Build the per-vertex stub bag `[0, 0, ..., 1, 1, ..., n-1, n-1, ...]`
107/// where each vertex appears exactly `k` times.
108fn build_stubs(n: u32, k: u32) -> Vec<VertexId> {
109    let n_us = n as usize;
110    let k_us = k as usize;
111    let mut stubs = Vec::with_capacity(n_us * k_us);
112    for v in 0..n {
113        for _ in 0..k {
114            stubs.push(v);
115        }
116    }
117    stubs
118}
119
120/// Configuration-model sampler (multigraph allowed). For the undirected
121/// case it picks pairs from a single bag without replacement; for the
122/// directed case it pairs one shuffled out-bag against one shuffled
123/// in-bag.
124fn configuration(
125    n: u32,
126    k: u32,
127    directed: bool,
128    rng: &mut SplitMix64,
129) -> Vec<(VertexId, VertexId)> {
130    if k == 0 {
131        return Vec::new();
132    }
133    let n_us = n as usize;
134    let k_us = k as usize;
135    let total_stubs = n_us * k_us;
136
137    if directed {
138        let mut out_bag = build_stubs(n, k);
139        let mut in_bag = build_stubs(n, k);
140        fisher_yates_shuffle(&mut out_bag, rng);
141        fisher_yates_shuffle(&mut in_bag, rng);
142        out_bag.into_iter().zip(in_bag).collect()
143    } else {
144        // Undirected: pop two random stubs at a time; replace-with-last
145        // keeps remaining draws uniform. Permits self-loops and
146        // parallel edges by design.
147        let mut bag = build_stubs(n, k);
148        let m = total_stubs / 2;
149        let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(m);
150        for _ in 0..m {
151            let i = rng.gen_index(bag.len());
152            let from = bag[i];
153            let last = bag.len() - 1;
154            bag.swap(i, last);
155            bag.pop();
156            let j = rng.gen_index(bag.len());
157            let to = bag[j];
158            let last = bag.len() - 1;
159            bag.swap(j, last);
160            bag.pop();
161            edges.push((from, to));
162        }
163        edges
164    }
165}
166
167/// Fast-heur simple sampler — undirected.
168fn fast_heur_undirected(
169    n: u32,
170    k: u32,
171    rng: &mut SplitMix64,
172) -> IgraphResult<Vec<(VertexId, VertexId)>> {
173    if k == 0 {
174        return Ok(Vec::new());
175    }
176    let n_us = n as usize;
177    let total_edges = (n_us * k as usize) / 2;
178
179    for _restart in 0..FAST_HEUR_MAX_RESTARTS {
180        let mut residual: Vec<u32> = vec![k; n_us];
181        let mut adj: Vec<HashSet<VertexId>> = vec![HashSet::new(); n_us];
182        let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(total_edges);
183
184        let mut attempt_failed = false;
185
186        loop {
187            // Rebuild stub bag from the residual degree sequence.
188            let mut stubs: Vec<VertexId> = Vec::new();
189            for (v, &d) in residual.iter().enumerate() {
190                for _ in 0..d {
191                    stubs.push(v as VertexId);
192                }
193            }
194            if stubs.is_empty() {
195                break;
196            }
197            fisher_yates_shuffle(&mut stubs, rng);
198
199            // Zero residuals; carry forward only the stubs that fail to
200            // pair this sweep.
201            residual.fill(0);
202            let mut incomplete: HashSet<VertexId> = HashSet::new();
203
204            // Walk the shuffled stubs two at a time.
205            for pair in stubs.chunks_exact(2) {
206                let mut from = pair[0];
207                let mut to = pair[1];
208                if from > to {
209                    std::mem::swap(&mut from, &mut to);
210                }
211                if from == to || adj[from as usize].contains(&to) {
212                    residual[from as usize] += 1;
213                    residual[to as usize] += 1;
214                    incomplete.insert(from);
215                    incomplete.insert(to);
216                } else {
217                    adj[from as usize].insert(to);
218                    edges.push((from, to));
219                }
220            }
221
222            if incomplete.is_empty() {
223                return Ok(edges);
224            }
225
226            // Feasibility: is there at least one pair (a, b) in
227            // `incomplete` with a != b and no existing edge? If not,
228            // this attempt is wedged — restart from scratch.
229            if !undirected_pair_feasible(&incomplete, &adj) {
230                attempt_failed = true;
231                break;
232            }
233        }
234
235        if !attempt_failed {
236            // Logically unreachable — we either return inside the loop
237            // when `incomplete` is empty or break with `attempt_failed`.
238            // The `break` happens only on the `stubs.is_empty()` early
239            // exit, which itself implies success. Guard against future
240            // refactors.
241            // No edges produced and no incomplete vertices → empty graph.
242            return Ok(edges);
243        }
244    }
245
246    Err(IgraphError::Internal(
247        "k_regular_game: fast-heur exceeded restart budget; try multiple=true",
248    ))
249}
250
251fn undirected_pair_feasible(incomplete: &HashSet<VertexId>, adj: &[HashSet<VertexId>]) -> bool {
252    let mut sorted: Vec<VertexId> = incomplete.iter().copied().collect();
253    sorted.sort_unstable();
254    for (i, &a) in sorted.iter().enumerate() {
255        for &b in sorted.iter().skip(i + 1) {
256            // `a < b` by construction after sort, so the adjacency
257            // lookup mirrors the canonical (min, max) edge encoding
258            // used while emitting edges above.
259            if !adj[a as usize].contains(&b) {
260                return true;
261            }
262        }
263    }
264    false
265}
266
267/// Fast-heur simple sampler — directed.
268fn fast_heur_directed(
269    n: u32,
270    k: u32,
271    rng: &mut SplitMix64,
272) -> IgraphResult<Vec<(VertexId, VertexId)>> {
273    if k == 0 {
274        return Ok(Vec::new());
275    }
276    let n_us = n as usize;
277    let total_edges = n_us * k as usize;
278
279    for _restart in 0..FAST_HEUR_MAX_RESTARTS {
280        let mut residual_out: Vec<u32> = vec![k; n_us];
281        let mut residual_in: Vec<u32> = vec![k; n_us];
282        let mut adj: Vec<HashSet<VertexId>> = vec![HashSet::new(); n_us];
283        let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(total_edges);
284
285        let mut attempt_failed = false;
286
287        loop {
288            let mut out_stubs: Vec<VertexId> = Vec::new();
289            let mut in_stubs: Vec<VertexId> = Vec::new();
290            for v in 0..n_us {
291                for _ in 0..residual_out[v] {
292                    out_stubs.push(v as VertexId);
293                }
294                for _ in 0..residual_in[v] {
295                    in_stubs.push(v as VertexId);
296                }
297            }
298            if out_stubs.is_empty() {
299                break;
300            }
301            fisher_yates_shuffle(&mut out_stubs, rng);
302
303            residual_out.fill(0);
304            residual_in.fill(0);
305            let mut incomplete_out: HashSet<VertexId> = HashSet::new();
306            let mut incomplete_in: HashSet<VertexId> = HashSet::new();
307
308            for (&from, &to) in out_stubs.iter().zip(in_stubs.iter()) {
309                if from == to || adj[from as usize].contains(&to) {
310                    residual_out[from as usize] += 1;
311                    residual_in[to as usize] += 1;
312                    incomplete_out.insert(from);
313                    incomplete_in.insert(to);
314                } else {
315                    adj[from as usize].insert(to);
316                    edges.push((from, to));
317                }
318            }
319
320            if incomplete_out.is_empty() {
321                return Ok(edges);
322            }
323            if !directed_pair_feasible(&incomplete_out, &incomplete_in, &adj) {
324                attempt_failed = true;
325                break;
326            }
327        }
328
329        if !attempt_failed {
330            return Ok(edges);
331        }
332    }
333
334    Err(IgraphError::Internal(
335        "k_regular_game: fast-heur exceeded restart budget; try multiple=true",
336    ))
337}
338
339fn directed_pair_feasible(
340    incomplete_out: &HashSet<VertexId>,
341    incomplete_in: &HashSet<VertexId>,
342    adj: &[HashSet<VertexId>],
343) -> bool {
344    for &from in incomplete_out {
345        for &to in incomplete_in {
346            if from != to && !adj[from as usize].contains(&to) {
347                return true;
348            }
349        }
350    }
351    false
352}
353
354/// Generate a random k-regular graph on `n` vertices.
355///
356/// Every vertex has degree exactly `k` (in the directed case, both
357/// in-degree and out-degree are `k`).
358///
359/// * `n` — vertex count.
360/// * `k` — degree.
361/// * `directed` — if `true`, returns a directed graph where every
362///   vertex has out-degree = in-degree = `k`.
363/// * `multiple` — if `true`, samples via the configuration model and
364///   permits self-loops and parallel edges; if `false`, samples via the
365///   fast-heuristic simple-graph sampler.
366/// * `seed` — initialises an internal `SplitMix64` PRNG. The same
367///   `(n, k, directed, multiple, seed)` always yields the same graph.
368///
369/// # Errors
370///
371/// * `InvalidArgument` — `n * k` overflows `u32`.
372/// * `InvalidArgument` — undirected and `n * k` is odd (handshake
373///   violation).
374/// * `InvalidArgument` — `multiple = false` and `k > n - 1` (no simple
375///   realisation exists).
376/// * `Internal` — `multiple = false` and the fast-heur sampler
377///   exceeded its restart budget. Switch to `multiple = true` or pick a
378///   smaller `k`.
379///
380/// # Examples
381///
382/// ```
383/// use rust_igraph::k_regular_game;
384///
385/// // 20-vertex 4-regular simple undirected graph.
386/// let g = k_regular_game(20, 4, false, false, 0xC0FF_EE00).unwrap();
387/// assert_eq!(g.vcount(), 20);
388/// assert_eq!(g.ecount(), 40);  // n * k / 2
389/// assert!(!g.is_directed());
390/// ```
391pub fn k_regular_game(
392    n: u32,
393    k: u32,
394    directed: bool,
395    multiple: bool,
396    seed: u64,
397) -> IgraphResult<Graph> {
398    validate(n, k, directed, multiple)?;
399    if n == 0 {
400        return Graph::new(0, directed);
401    }
402    let mut rng = SplitMix64::new(seed);
403    let edges = if multiple {
404        configuration(n, k, directed, &mut rng)
405    } else if directed {
406        fast_heur_directed(n, k, &mut rng)?
407    } else {
408        fast_heur_undirected(n, k, &mut rng)?
409    };
410
411    let mut g = Graph::new(n, directed)?;
412    g.add_edges(edges)?;
413    Ok(g)
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    fn degree_sequence(g: &Graph) -> Vec<u32> {
421        let mut deg = vec![0u32; g.vcount() as usize];
422        let m = u32::try_from(g.ecount()).expect("ecount fits in u32 in tests");
423        for eid in 0..m {
424            let (u, v) = g.edge(eid).expect("edge id in bounds");
425            if g.is_directed() {
426                deg[u as usize] += 1;
427            } else {
428                // Self-loop contributes 2 to its endpoint's undirected
429                // degree; the two increments coincide on u == v.
430                deg[u as usize] += 1;
431                deg[v as usize] += 1;
432            }
433        }
434        deg
435    }
436
437    fn out_degree_sequence(g: &Graph) -> Vec<u32> {
438        let mut deg = vec![0u32; g.vcount() as usize];
439        let m = u32::try_from(g.ecount()).expect("ecount fits in u32 in tests");
440        for eid in 0..m {
441            let (u, _v) = g.edge(eid).expect("edge id in bounds");
442            deg[u as usize] += 1;
443        }
444        deg
445    }
446
447    fn in_degree_sequence(g: &Graph) -> Vec<u32> {
448        let mut deg = vec![0u32; g.vcount() as usize];
449        let m = u32::try_from(g.ecount()).expect("ecount fits in u32 in tests");
450        for eid in 0..m {
451            let (_u, v) = g.edge(eid).expect("edge id in bounds");
452            deg[v as usize] += 1;
453        }
454        deg
455    }
456
457    fn is_simple(g: &Graph) -> bool {
458        let mut seen: HashSet<(VertexId, VertexId)> = HashSet::new();
459        let m = u32::try_from(g.ecount()).expect("ecount fits in u32 in tests");
460        for eid in 0..m {
461            let (u, v) = g.edge(eid).expect("edge id in bounds");
462            if u == v {
463                return false;
464            }
465            let key = if g.is_directed() {
466                (u, v)
467            } else {
468                (u.min(v), u.max(v))
469            };
470            if !seen.insert(key) {
471                return false;
472            }
473        }
474        true
475    }
476
477    #[test]
478    fn empty_graph() {
479        let g = k_regular_game(0, 0, false, false, 1).expect("empty");
480        assert_eq!(g.vcount(), 0);
481        assert_eq!(g.ecount(), 0);
482    }
483
484    #[test]
485    fn k_zero_returns_edgeless() {
486        let g = k_regular_game(7, 0, false, false, 1).expect("k=0");
487        assert_eq!(g.vcount(), 7);
488        assert_eq!(g.ecount(), 0);
489    }
490
491    #[test]
492    fn undirected_simple_2regular_on_6() {
493        let g = k_regular_game(6, 2, false, false, 0xABCD).expect("ok");
494        assert_eq!(g.vcount(), 6);
495        assert_eq!(g.ecount(), 6); // n*k/2
496        assert!(is_simple(&g));
497        for d in degree_sequence(&g) {
498            assert_eq!(d, 2);
499        }
500    }
501
502    #[test]
503    fn undirected_simple_4regular_on_10() {
504        let g = k_regular_game(10, 4, false, false, 0xDEAD_BEEF).expect("ok");
505        assert_eq!(g.ecount(), 20);
506        assert!(is_simple(&g));
507        for d in degree_sequence(&g) {
508            assert_eq!(d, 4);
509        }
510    }
511
512    #[test]
513    fn directed_simple_3regular_on_8() {
514        let g = k_regular_game(8, 3, true, false, 0x1234_5678).expect("ok");
515        assert_eq!(g.ecount(), 24); // n*k
516        assert!(is_simple(&g));
517        for d in out_degree_sequence(&g) {
518            assert_eq!(d, 3);
519        }
520        for d in in_degree_sequence(&g) {
521            assert_eq!(d, 3);
522        }
523    }
524
525    #[test]
526    fn undirected_multi_2regular_on_3() {
527        // n*k = 6 even, so the handshake is satisfied. Self-loops and
528        // parallel edges are allowed.
529        let g = k_regular_game(3, 2, false, true, 0xFEED).expect("ok");
530        assert_eq!(g.ecount(), 3);
531        for d in degree_sequence(&g) {
532            assert_eq!(d, 2);
533        }
534    }
535
536    #[test]
537    fn directed_multi_3regular_on_4() {
538        let g = k_regular_game(4, 3, true, true, 0xCAFE).expect("ok");
539        assert_eq!(g.ecount(), 12);
540        for d in out_degree_sequence(&g) {
541            assert_eq!(d, 3);
542        }
543        for d in in_degree_sequence(&g) {
544            assert_eq!(d, 3);
545        }
546    }
547
548    #[test]
549    fn undirected_simple_handshake_violation() {
550        // n=5, k=3 → n*k=15 odd
551        let err = k_regular_game(5, 3, false, false, 1).unwrap_err();
552        assert!(matches!(err, IgraphError::InvalidArgument(_)));
553    }
554
555    #[test]
556    fn undirected_multi_handshake_violation() {
557        let err = k_regular_game(5, 3, false, true, 1).unwrap_err();
558        assert!(matches!(err, IgraphError::InvalidArgument(_)));
559    }
560
561    #[test]
562    fn simple_degree_too_high() {
563        // k=n-1 is OK (complete graph); k=n is not.
564        let g = k_regular_game(5, 4, false, false, 7).expect("k = n - 1 ok");
565        assert_eq!(g.ecount(), 10);
566        assert!(is_simple(&g));
567
568        let err = k_regular_game(5, 5, false, false, 7).unwrap_err();
569        assert!(matches!(err, IgraphError::InvalidArgument(_)));
570    }
571
572    #[test]
573    fn determinism_same_seed_same_graph() {
574        let a = k_regular_game(20, 4, false, false, 0x42).expect("a");
575        let b = k_regular_game(20, 4, false, false, 0x42).expect("b");
576        assert_eq!(a.vcount(), b.vcount());
577        assert_eq!(a.ecount(), b.ecount());
578        let collect = |g: &Graph| -> Vec<(VertexId, VertexId)> {
579            let m = u32::try_from(g.ecount()).expect("fits");
580            (0..m).map(|e| g.edge(e).expect("ok")).collect()
581        };
582        assert_eq!(collect(&a), collect(&b));
583    }
584
585    #[test]
586    fn different_seeds_different_graphs() {
587        let a = k_regular_game(20, 4, false, false, 1).expect("a");
588        let b = k_regular_game(20, 4, false, false, 2).expect("b");
589        let collect = |g: &Graph| -> Vec<(VertexId, VertexId)> {
590            let m = u32::try_from(g.ecount()).expect("fits");
591            (0..m).map(|e| g.edge(e).expect("ok")).collect()
592        };
593        // With n=20, k=4 there are vastly more than 2 realisations,
594        // so two distinct seeds should almost certainly produce
595        // distinct edge lists.
596        assert_ne!(collect(&a), collect(&b));
597    }
598
599    #[test]
600    fn undirected_simple_k_equals_n_minus_one_is_complete() {
601        // 4-regular on 5 vertices = K_5.
602        let g = k_regular_game(5, 4, false, false, 0).expect("ok");
603        assert_eq!(g.ecount(), 10); // C(5,2)
604        assert!(is_simple(&g));
605        // Every pair must be present.
606        let mut seen: HashSet<(VertexId, VertexId)> = HashSet::new();
607        let m = u32::try_from(g.ecount()).expect("fits");
608        for eid in 0..m {
609            let (u, v) = g.edge(eid).expect("ok");
610            let key = (u.min(v), u.max(v));
611            seen.insert(key);
612        }
613        for a in 0..5u32 {
614            for b in (a + 1)..5 {
615                assert!(seen.contains(&(a, b)), "missing edge {a}-{b}");
616            }
617        }
618    }
619
620    #[test]
621    fn n_times_k_overflow() {
622        let err = k_regular_game(u32::MAX, 2, false, false, 1).unwrap_err();
623        assert!(matches!(err, IgraphError::InvalidArgument(_)));
624    }
625
626    #[test]
627    fn directed_simple_no_self_loops() {
628        let g = k_regular_game(7, 3, true, false, 0x7777).expect("ok");
629        let m = u32::try_from(g.ecount()).expect("fits");
630        for eid in 0..m {
631            let (u, v) = g.edge(eid).expect("ok");
632            assert_ne!(u, v, "self-loop in simple directed run");
633        }
634    }
635}
636
637#[cfg(all(test, feature = "proptest-harness"))]
638mod proptests {
639    use super::*;
640    use proptest::prelude::*;
641
642    proptest! {
643        #![proptest_config(ProptestConfig {
644            cases: 64,
645            .. ProptestConfig::default()
646        })]
647
648        // Undirected simple: every vertex has degree exactly k, no
649        // self-loops, no parallel edges.
650        #[test]
651        fn undirected_simple_invariants(
652            n in 2u32..=24,
653            k_in in 0u32..=6,
654            seed in any::<u64>(),
655        ) {
656            let k = if n % 2 == 1 && k_in % 2 == 1 { k_in + 1 } else { k_in };
657            let k = if k > n.saturating_sub(1) { n.saturating_sub(1) } else { k };
658            let k = if n % 2 == 1 && k % 2 == 1 { k.saturating_sub(1) } else { k };
659
660            let g = k_regular_game(n, k, false, false, seed)
661                .expect("valid params");
662            prop_assert_eq!(g.vcount() as u32, n);
663
664            let n_us = n as usize;
665            let m_expected = (n_us * k as usize) / 2;
666            prop_assert_eq!(g.ecount(), m_expected);
667
668            let mut deg = vec![0u32; n_us];
669            let mut seen: std::collections::HashSet<(u32, u32)> =
670                std::collections::HashSet::new();
671            let m = u32::try_from(g.ecount()).expect("fits");
672            for eid in 0..m {
673                let (u, v) = g.edge(eid).expect("ok");
674                prop_assert_ne!(u, v, "self-loop in simple run");
675                let key = (u.min(v), u.max(v));
676                prop_assert!(seen.insert(key), "duplicate edge");
677                deg[u as usize] += 1;
678                deg[v as usize] += 1;
679            }
680            for d in deg {
681                prop_assert_eq!(d, k);
682            }
683        }
684
685        // Directed simple: out-deg = in-deg = k everywhere, no self,
686        // no duplicates within a direction.
687        #[test]
688        fn directed_simple_invariants(
689            n in 2u32..=18,
690            k in 0u32..=5,
691            seed in any::<u64>(),
692        ) {
693            let k = if k > n.saturating_sub(1) { n.saturating_sub(1) } else { k };
694            let g = k_regular_game(n, k, true, false, seed)
695                .expect("valid params");
696            let n_us = n as usize;
697            prop_assert_eq!(g.ecount(), n_us * k as usize);
698
699            let mut out_d = vec![0u32; n_us];
700            let mut in_d = vec![0u32; n_us];
701            let mut seen: std::collections::HashSet<(u32, u32)> =
702                std::collections::HashSet::new();
703            let m = u32::try_from(g.ecount()).expect("fits");
704            for eid in 0..m {
705                let (u, v) = g.edge(eid).expect("ok");
706                prop_assert_ne!(u, v, "self-loop");
707                prop_assert!(seen.insert((u, v)), "parallel directed edge");
708                out_d[u as usize] += 1;
709                in_d[v as usize] += 1;
710            }
711            for d in out_d {
712                prop_assert_eq!(d, k);
713            }
714            for d in in_d {
715                prop_assert_eq!(d, k);
716            }
717        }
718
719        // Undirected multigraph: degree = k everywhere (loops contribute
720        // 2). No graphicality constraint beyond parity.
721        #[test]
722        fn undirected_multi_invariants(
723            n in 1u32..=20,
724            k in 0u32..=8,
725            seed in any::<u64>(),
726        ) {
727            let n_us = n as usize;
728            let total = n_us * k as usize;
729            if total % 2 != 0 {
730                return Ok(());
731            }
732            let g = k_regular_game(n, k, false, true, seed)
733                .expect("parity ok");
734            prop_assert_eq!(g.ecount(), total / 2);
735            let mut deg = vec![0u32; n_us];
736            let m = u32::try_from(g.ecount()).expect("fits");
737            for eid in 0..m {
738                let (u, v) = g.edge(eid).expect("ok");
739                deg[u as usize] += 1;
740                deg[v as usize] += 1; // works for self-loops too
741            }
742            for d in deg {
743                prop_assert_eq!(d, k);
744            }
745        }
746
747        // Determinism: same (n,k,d,m,seed) → identical edge list.
748        #[test]
749        fn determinism(
750            n in 2u32..=16,
751            k in 0u32..=4,
752            directed in any::<bool>(),
753            multiple in any::<bool>(),
754            seed in any::<u64>(),
755        ) {
756            let n_us = n as usize;
757            let total = n_us * k as usize;
758            if !directed && (total % 2 != 0) {
759                return Ok(());
760            }
761            if !multiple && k > n.saturating_sub(1) {
762                return Ok(());
763            }
764            let a = k_regular_game(n, k, directed, multiple, seed)
765                .expect("valid");
766            let b = k_regular_game(n, k, directed, multiple, seed)
767                .expect("valid");
768            let collect = |g: &Graph| -> Vec<(u32, u32)> {
769                let m = u32::try_from(g.ecount()).expect("fits");
770                (0..m).map(|e| g.edge(e).expect("ok")).collect()
771            };
772            prop_assert_eq!(collect(&a), collect(&b));
773        }
774    }
775}