Skip to main content

rust_igraph/algorithms/games/
correlated.rs

1//! Correlated Erdős–Rényi graph generators (ALGO-GN-023).
2//!
3//! Counterparts of `igraph_correlated_game()` and
4//! `igraph_correlated_pair_game()` from
5//! `references/igraph/src/games/correlated.c` (lines 90-338).
6//!
7//! Given a simple `old_graph` on `n` vertices with target marginal
8//! density `p`, [`correlated_game`] returns a new graph whose adjacency
9//! vector has Pearson correlation `corr ∈ [0, 1]` with that of the
10//! original. The construction preserves the marginal density `p` by
11//! solving the elementary 2 × 2 contingency table:
12//!
13//! ```text
14//!   q     = p + corr · (1 − p)
15//!   p_del = 1 − q                        // P(drop | original edge)
16//!   p_add = (1 − q) · p / (1 − p)        // P(create | original non-edge)
17//! ```
18//!
19//! The per-position Bernoullis are sampled via the same Batagelj–Brandes
20//! geometric-skip trick used in [`crate::erdos_renyi_gnp`] (ALGO-GN-001):
21//! two `RNG_GEOM` streams skip directly over kept-or-skipped edge slots
22//! without visiting each `O(n²)` position. The deletion stream walks the
23//! `m` existing edges (in canonical-code order); the addition stream
24//! walks the `n(n−1)/2` undirected (or `n(n−1)` directed) candidate
25//! non-edges. A 3-way merge interleaves the kept edges, the deletes, and
26//! the additions in sorted code order.
27//!
28//! [`correlated_pair_game`] is the convenience wrapper that samples an
29//! `ER(n, p)` graph and a correlated counterpart from a single seed.
30//!
31//! ## References
32//!
33//! * Pedarsani, P. & Grossglauser, M. (2011). *On the privacy of
34//!   anonymized networks*. Proc. SIGKDD.
35//! * Barak, B. et al. (2018). *(Nearly) Efficient algorithms for the
36//!   graph matching problem on correlated random graphs*. `NeurIPS`.
37
38#![allow(
39    unknown_lints,
40    clippy::cast_possible_truncation,
41    clippy::cast_possible_wrap,
42    clippy::cast_precision_loss,
43    clippy::cast_sign_loss,
44    clippy::float_cmp,
45    clippy::many_single_char_names,
46    clippy::manual_midpoint,
47    clippy::too_many_lines
48)]
49
50use crate::algorithms::games::erdos_renyi::erdos_renyi_gnp;
51use crate::algorithms::properties::is_simple::is_simple;
52use crate::core::rng::SplitMix64;
53use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
54
55/// Encode an undirected `(from, to)` pair with `from < to` as a single
56/// 0-based code: `to·(to−1)/2 + from`. Range: `[0, n·(n−1)/2)`.
57fn u_code(from: VertexId, to: VertexId) -> u64 {
58    let (lo, hi) = if from < to { (from, to) } else { (to, from) };
59    let hi64 = u64::from(hi);
60    let lo64 = u64::from(lo);
61    hi64 * (hi64 - 1) / 2 + lo64
62}
63
64/// Encode a directed `(from, to)` pair with `from != to` as a single
65/// code in `[0, n·(n−1))`. Matches `D_CODE` in upstream `correlated.c`.
66fn d_code(from: VertexId, to: VertexId, n: u32) -> u64 {
67    let n64 = u64::from(n);
68    let from64 = u64::from(from);
69    let to64 = u64::from(to);
70    // The clever trick: edges with `to == n-1` are filed into the
71    // "diagonal hole" of column `from` (slot `from·n + from`); other
72    // edges live at their natural `to·n + from` slot, so the diagonal
73    // holes get filled exactly once.
74    if to64 == n64 - 1 {
75        from64 * n64 + from64
76    } else {
77        to64 * n64 + from64
78    }
79}
80
81/// Decode an undirected code into `(from, to)` with `from < to`.
82/// Inverts `U_CODE` from `correlated.c`.
83fn u_decode(code: u64) -> (VertexId, VertexId) {
84    // Standard upper-triangular inversion. We do the floating-point
85    // estimate then verify in exact integer arithmetic — the same
86    // bump-up loop used in `erdos_renyi::decode_pair`.
87    let code_f = code as f64;
88    let to_f = ((8.0 * code_f + 1.0).sqrt() + 1.0) / 2.0;
89    let mut to = to_f.trunc() as u64;
90    if to < 1 {
91        to = 1;
92    }
93    let mut from = code - to * (to - 1) / 2;
94    while from >= to {
95        to += 1;
96        from = code - to * (to - 1) / 2;
97    }
98    debug_assert!(from < to);
99    (from as VertexId, to as VertexId)
100}
101
102/// Decode a directed code into `(from, to)` with `from != to`.
103/// Inverts `D_CODE` from `correlated.c`.
104fn d_decode(code: u64, n: u32) -> (VertexId, VertexId) {
105    let n64 = u64::from(n);
106    let to0 = code / n64;
107    let from = code - to0 * n64;
108    // `from == to` flags "I lived in a diagonal hole" → the real `to`
109    // is `n − 1` (the column whose edges fill those holes).
110    let to = if from == to0 { n64 - 1 } else { to0 };
111    debug_assert!(from < n64 && to < n64 && from != to);
112    (from as VertexId, to as VertexId)
113}
114
115/// Either canonical-code helper, selected by `directed`.
116fn code(from: VertexId, to: VertexId, n: u32, directed: bool) -> u64 {
117    if directed {
118        d_code(from, to, n)
119    } else {
120        u_code(from, to)
121    }
122}
123
124/// Either decode helper, selected by `directed`.
125fn decode(c: u64, n: u32, directed: bool) -> (VertexId, VertexId) {
126    if directed {
127        d_decode(c, n)
128    } else {
129        u_decode(c)
130    }
131}
132
133/// Total candidate-edge slots for a simple graph on `n` vertices.
134fn slot_count(n: u32, directed: bool) -> u64 {
135    let n64 = u64::from(n);
136    if n64 < 2 {
137        0
138    } else if directed {
139        n64 * (n64 - 1)
140    } else {
141        n64 * (n64 - 1) / 2
142    }
143}
144
145/// Walk an old graph's edges and emit `(from, to, code)` sorted by
146/// `code`. Codes are unique because `old_graph` is simple.
147fn coded_edges(old_graph: &Graph) -> IgraphResult<Vec<(VertexId, VertexId, u64)>> {
148    let n = old_graph.vcount();
149    let directed = old_graph.is_directed();
150    let m = u32::try_from(old_graph.ecount()).map_err(|_| {
151        IgraphError::Internal("correlated_game: edge count exceeds u32 — bug in caller")
152    })?;
153    let mut out: Vec<(VertexId, VertexId, u64)> = Vec::with_capacity(m as usize);
154    for eid in 0..m {
155        let (f, t) = old_graph.edge(eid)?;
156        out.push((f, t, code(f, t, n, directed)));
157    }
158    out.sort_unstable_by_key(|&(_, _, c)| c);
159    Ok(out)
160}
161
162/// Compute and validate `(p_del, p_add)` from `(corr, p)`. Both are in
163/// `[0, 1]`; either may be `0.0` (no draws on that side).
164fn compute_conditionals(corr: f64, p: f64) -> (f64, f64) {
165    let q = p + corr * (1.0 - p);
166    let p_del = 1.0 - q;
167    let p_add = (1.0 - q) * (p / (1.0 - p));
168    // Clamp tiny FP drift below 0 / above 1.
169    let p_del = p_del.clamp(0.0, 1.0);
170    let p_add = p_add.clamp(0.0, 1.0);
171    (p_del, p_add)
172}
173
174/// Apply the inverse of `permutation` to every endpoint in `edges`.
175///
176/// In the upstream C: `perm[i]` names the *old* vertex that becomes
177/// vertex `i` in the *new* graph. So `inv[perm[i]] = i`, and to relabel
178/// an edge whose endpoint is `v` in the old graph we look up `inv[v]`.
179fn apply_permutation(
180    edges: &mut [(VertexId, VertexId)],
181    permutation: &[VertexId],
182) -> IgraphResult<()> {
183    let n = permutation.len();
184    let n_u32 = u32::try_from(n)
185        .map_err(|_| IgraphError::Internal("correlated_game: permutation length exceeds u32"))?;
186    let mut inv: Vec<i64> = vec![-1; n];
187    for (i, &v) in permutation.iter().enumerate() {
188        if v >= n_u32 {
189            return Err(IgraphError::InvalidArgument(format!(
190                "correlated_game: permutation entry {v} out of range for n = {n}"
191            )));
192        }
193        let slot = v as usize;
194        if inv[slot] != -1 {
195            return Err(IgraphError::InvalidArgument(format!(
196                "correlated_game: permutation entry {v} appears more than once"
197            )));
198        }
199        inv[slot] = i as i64;
200    }
201    // All slots filled iff perm is a bijection.
202    for &val in &inv {
203        if val == -1 {
204            return Err(IgraphError::InvalidArgument(
205                "correlated_game: permutation is not a bijection".to_string(),
206            ));
207        }
208    }
209    for edge in edges.iter_mut() {
210        edge.0 = inv[edge.0 as usize] as VertexId;
211        edge.1 = inv[edge.1 as usize] as VertexId;
212    }
213    Ok(())
214}
215
216/// Drive the geometric-skip stream and collect every index `< cap` it
217/// visits. Mirrors the `RNG_GEOM` loop in `correlated.c`.
218fn skip_indices(rng: &mut SplitMix64, prob: f64, cap: u64) -> Vec<u64> {
219    if prob <= 0.0 || cap == 0 {
220        return Vec::new();
221    }
222    let cap_f = cap as f64;
223    let mut last = rng.gen_geom(prob);
224    let mut out: Vec<u64> = Vec::new();
225    while last < cap_f {
226        let idx = last.trunc() as u64;
227        if idx >= cap {
228            break;
229        }
230        out.push(idx);
231        last += rng.gen_geom(prob);
232        last += 1.0;
233    }
234    out
235}
236
237/// Generate a random graph whose adjacency vector is Pearson-correlated
238/// to that of `old_graph` at level `corr`.
239///
240/// * `old_graph` — input. Must be **simple** (no self-loops, no
241///   multi-edges). Vertex count `n` and `directed` are inherited.
242/// * `corr` — target correlation, in `[0, 1]`. `0` is independent
243///   (sampled from `ER(n, p)`); `1` is a relabeled copy of `old_graph`.
244/// * `p` — marginal edge probability, in the **open** `(0, 1)`. Usually
245///   matches the empirical density of `old_graph`.
246/// * `permutation` — optional vertex relabeling. `perm[i]` names the
247///   vertex in `old_graph` that becomes vertex `i` in the new graph.
248///   Must be a bijection of `[0, n)`.
249/// * `seed` — drives the internal `SplitMix64` PRNG.
250///
251/// # Errors
252///
253/// * `corr` not in `[0, 1]`, `p` not in `(0, 1)`, either is NaN/∞;
254/// * `permutation.len() != old_graph.vcount()`;
255/// * `permutation` is not a bijection of `[0, n)`;
256/// * `old_graph` is not simple.
257///
258/// # Examples
259///
260/// ```
261/// use rust_igraph::{Graph, correlated_game, erdos_renyi_gnp};
262/// let old = erdos_renyi_gnp(20, 0.3, false, false, 11).unwrap();
263/// // Strong correlation: most of old's edges survive.
264/// let new_g = correlated_game(&old, 0.9, 0.3, None, 17).unwrap();
265/// assert_eq!(new_g.vcount(), 20);
266/// assert_eq!(new_g.is_directed(), false);
267/// ```
268pub fn correlated_game(
269    old_graph: &Graph,
270    corr: f64,
271    p: f64,
272    permutation: Option<&[VertexId]>,
273    seed: u64,
274) -> IgraphResult<Graph> {
275    if !corr.is_finite() || !(0.0..=1.0).contains(&corr) {
276        return Err(IgraphError::InvalidArgument(format!(
277            "correlated_game: corr must be in [0, 1] (got {corr})"
278        )));
279    }
280    if !p.is_finite() || p <= 0.0 || p >= 1.0 {
281        return Err(IgraphError::InvalidArgument(format!(
282            "correlated_game: p must be in the open interval (0, 1) (got {p})"
283        )));
284    }
285
286    let n = old_graph.vcount();
287    let directed = old_graph.is_directed();
288
289    if let Some(perm) = permutation {
290        if perm.len() != n as usize {
291            return Err(IgraphError::InvalidArgument(format!(
292                "correlated_game: permutation length {} does not match vertex count {}",
293                perm.len(),
294                n
295            )));
296        }
297    }
298
299    // Defensive verification — upstream also checks this.
300    if !is_simple(old_graph)? {
301        return Err(IgraphError::InvalidArgument(
302            "correlated_game: old_graph must be a simple graph".to_string(),
303        ));
304    }
305
306    // Special cases.
307    if corr == 0.0 {
308        // Drop old graph entirely; return a fresh ER(n, p) at the same
309        // shape. The C kernel takes this short-circuit explicitly.
310        let mut g = erdos_renyi_gnp(n, p, directed, false, seed)?;
311        if let Some(perm) = permutation {
312            apply_permutation_in_place(&mut g, perm)?;
313        }
314        return Ok(g);
315    }
316    if corr == 1.0 {
317        // Copy old graph's edges (optionally relabeled), no RNG draws.
318        let mut edges: Vec<(VertexId, VertexId)> =
319            (0..u32::try_from(old_graph.ecount()).map_err(|_| {
320                IgraphError::Internal("correlated_game: ecount exceeds u32 in corr=1 branch")
321            })?)
322                .map(|eid| old_graph.edge(eid))
323                .collect::<IgraphResult<Vec<_>>>()?;
324        if let Some(perm) = permutation {
325            apply_permutation(&mut edges, perm)?;
326        }
327        let mut g = Graph::new(n, directed)?;
328        g.add_edges(edges)?;
329        return Ok(g);
330    }
331
332    let (p_del, p_add) = compute_conditionals(corr, p);
333    let coded = coded_edges(old_graph)?;
334    let m = coded.len() as u64;
335    let cap = slot_count(n, directed);
336    let missing = cap - m;
337
338    let mut rng = SplitMix64::new(seed);
339
340    // Step 1: delete indices, walked over the m existing edges by their
341    // *position* in the sorted edge list. We recode immediately.
342    let delete_positions = skip_indices(&mut rng, p_del, m);
343    let mut delete_codes: Vec<u64> = delete_positions
344        .into_iter()
345        .map(|pos| coded[pos as usize].2)
346        .collect();
347    delete_codes.sort_unstable();
348
349    // Step 2: add indices, walked over the `missing` non-edge slots.
350    // These are *position* indices over the gap sequence — when we
351    // merge below, each add index gets shifted by `p_e` (the count of
352    // original edges scanned so far) to land on its canonical code.
353    let add_positions = skip_indices(&mut rng, p_add, missing);
354
355    // Step 3: 3-way merge. p_e walks `coded`, p_d walks delete_codes,
356    // p_a walks add_positions.
357    let mut new_edges: Vec<(VertexId, VertexId)> =
358        Vec::with_capacity(coded.len() + add_positions.len());
359    let mut p_e: usize = 0;
360    let mut p_d: usize = 0;
361    let mut p_a: usize = 0;
362    // Use f64-INF / u64-MAX sentinels; the C kernel uses double INF but
363    // u64 saturation is equivalent and avoids the float comparisons.
364    let no_e = coded.len();
365    let no_d = delete_codes.len();
366    let no_a = add_positions.len();
367    let inf: u128 = u128::MAX;
368    let mut next_e: u128 = if p_e < no_e {
369        u128::from(coded[p_e].2)
370    } else {
371        inf
372    };
373    let mut next_a: u128 = if p_a < no_a {
374        u128::from(add_positions[p_a]) + u128::from(p_e as u64)
375    } else {
376        inf
377    };
378    let mut next_d: u128 = if p_d < no_d {
379        u128::from(delete_codes[p_d])
380    } else {
381        inf
382    };
383    while next_e != inf || next_a != inf || next_d != inf {
384        if next_e <= next_a && next_e < next_d {
385            // Keep an original edge.
386            let (f, t, _) = coded[p_e];
387            new_edges.push((f, t));
388            p_e += 1;
389            next_e = if p_e < no_e {
390                u128::from(coded[p_e].2)
391            } else {
392                inf
393            };
394            // next_a depends on p_e — refresh.
395            next_a = if p_a < no_a {
396                u128::from(add_positions[p_a]) + u128::from(p_e as u64)
397            } else {
398                inf
399            };
400        } else if next_e <= next_a && next_e == next_d {
401            // Delete: skip the edge and advance the delete cursor.
402            p_e += 1;
403            next_e = if p_e < no_e {
404                u128::from(coded[p_e].2)
405            } else {
406                inf
407            };
408            next_a = if p_a < no_a {
409                u128::from(add_positions[p_a]) + u128::from(p_e as u64)
410            } else {
411                inf
412            };
413            p_d += 1;
414            next_d = if p_d < no_d {
415                u128::from(delete_codes[p_d])
416            } else {
417                inf
418            };
419        } else {
420            // Add a new edge.
421            let code64 = u64::try_from(next_a)
422                .map_err(|_| IgraphError::Internal("correlated_game: add-code overflow"))?;
423            let (f, t) = decode(code64, n, directed);
424            new_edges.push((f, t));
425            p_a += 1;
426            next_a = if p_a < no_a {
427                u128::from(add_positions[p_a]) + u128::from(p_e as u64)
428            } else {
429                inf
430            };
431        }
432    }
433
434    if let Some(perm) = permutation {
435        apply_permutation(&mut new_edges, perm)?;
436    }
437
438    let mut g = Graph::new(n, directed)?;
439    g.add_edges(new_edges)?;
440    Ok(g)
441}
442
443/// Apply `permutation` to every edge of an already-built `Graph`.
444///
445/// This rebuilds a fresh graph with the relabeled edges; mutating a
446/// `Graph` in place is not part of the public API.
447fn apply_permutation_in_place(g: &mut Graph, perm: &[VertexId]) -> IgraphResult<()> {
448    let m = u32::try_from(g.ecount())
449        .map_err(|_| IgraphError::Internal("correlated_game: ecount exceeds u32"))?;
450    let mut edges: Vec<(VertexId, VertexId)> = (0..m)
451        .map(|eid| g.edge(eid))
452        .collect::<IgraphResult<Vec<_>>>()?;
453    apply_permutation(&mut edges, perm)?;
454    let n = g.vcount();
455    let directed = g.is_directed();
456    let mut fresh = Graph::new(n, directed)?;
457    fresh.add_edges(edges)?;
458    *g = fresh;
459    Ok(())
460}
461
462/// Sample two random graphs with target adjacency-vector correlation
463/// `corr`. Builds `graph1` via `ER(n, p)` and `graph2` via
464/// [`correlated_game`] against `graph1`.
465///
466/// Counterpart of `igraph_correlated_pair_game` in
467/// `references/igraph/src/games/correlated.c:330`.
468///
469/// * `n` — vertex count for both graphs.
470/// * `corr` — target correlation, in `[0, 1]`.
471/// * `p` — marginal edge probability, in the open `(0, 1)`.
472/// * `directed` — whether both graphs are directed.
473/// * `permutation` — optional, applied to `graph2` only (see
474///   [`correlated_game`]).
475/// * `seed` — drives a single internal `SplitMix64`. `graph1` consumes
476///   its initial draws; `graph2` is sampled from the continuation.
477///
478/// # Errors
479///
480/// Same conditions as [`correlated_game`], plus any error from
481/// [`crate::erdos_renyi_gnp`] when building `graph1`.
482///
483/// # Examples
484///
485/// ```
486/// use rust_igraph::correlated_pair_game;
487/// let (g1, g2) = correlated_pair_game(40, 0.7, 0.2, false, None, 99).unwrap();
488/// assert_eq!(g1.vcount(), 40);
489/// assert_eq!(g2.vcount(), 40);
490/// ```
491pub fn correlated_pair_game(
492    n: u32,
493    corr: f64,
494    p: f64,
495    directed: bool,
496    permutation: Option<&[VertexId]>,
497    seed: u64,
498) -> IgraphResult<(Graph, Graph)> {
499    let g1 = erdos_renyi_gnp(n, p, directed, false, seed)?;
500    // Use a distinct seed derivation for g2 so the same seed → distinct
501    // RNG state, mirroring the C implementation's effective behavior
502    // (C uses a single shared RNG state that carries over).
503    let g2_seed = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
504    let g2 = correlated_game(&g1, corr, p, permutation, g2_seed)?;
505    Ok((g1, g2))
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    fn ecount_of(g: &Graph) -> usize {
513        g.ecount()
514    }
515
516    /// Brute-force adjacency-overlap: count edges present in both graphs
517    /// when treated as undirected, ignoring multiplicity.
518    fn edge_set(g: &Graph) -> std::collections::HashSet<(VertexId, VertexId)> {
519        let mut s = std::collections::HashSet::new();
520        let m = u32::try_from(g.ecount()).unwrap();
521        for eid in 0..m {
522            let (f, t) = g.edge(eid).unwrap();
523            let key = if g.is_directed() || f < t {
524                (f, t)
525            } else {
526                (t, f)
527            };
528            s.insert(key);
529        }
530        s
531    }
532
533    fn assert_no_self_loops(g: &Graph) {
534        let m = u32::try_from(g.ecount()).unwrap();
535        for eid in 0..m {
536            let (f, t) = g.edge(eid).unwrap();
537            assert_ne!(f, t, "self-loop at edge {eid}");
538        }
539    }
540
541    fn assert_simple(g: &Graph) {
542        assert!(is_simple(g).unwrap(), "not simple");
543    }
544
545    #[test]
546    fn u_code_round_trip() {
547        for to in 1u32..30 {
548            for from in 0..to {
549                let c = u_code(from, to);
550                let (f2, t2) = u_decode(c);
551                assert_eq!((f2, t2), (from, to), "u_code({from}, {to}) = {c}");
552            }
553        }
554    }
555
556    #[test]
557    fn d_code_round_trip() {
558        for n in [2u32, 3, 5, 8, 13] {
559            for from in 0..n {
560                for to in 0..n {
561                    if from == to {
562                        continue;
563                    }
564                    let c = d_code(from, to, n);
565                    let cap = u64::from(n) * (u64::from(n) - 1);
566                    assert!(c < cap, "d_code({from}, {to}, {n}) = {c} >= cap {cap}");
567                    let (f2, t2) = d_decode(c, n);
568                    assert_eq!((f2, t2), (from, to), "d_code({from}, {to}, {n}) = {c}");
569                }
570            }
571        }
572    }
573
574    #[test]
575    fn d_code_is_bijection() {
576        // Every code in [0, n(n-1)) decodes to a unique (f, t) with f != t.
577        for n in [2u32, 3, 5, 10] {
578            let cap = u64::from(n) * (u64::from(n) - 1);
579            let mut seen = std::collections::HashSet::new();
580            for c in 0..cap {
581                let (f, t) = d_decode(c, n);
582                assert_ne!(f, t);
583                assert!(seen.insert((f, t)), "duplicate decoded pair at code {c}");
584            }
585            assert_eq!(seen.len(), cap as usize);
586        }
587    }
588
589    #[test]
590    fn empty_old_graph_returns_empty_new() {
591        let old = Graph::new(0, false).unwrap();
592        let new_g = correlated_game(&old, 0.5, 0.5, None, 1).unwrap();
593        assert_eq!(new_g.vcount(), 0);
594        assert_eq!(new_g.ecount(), 0);
595    }
596
597    #[test]
598    fn singleton_old_graph_returns_singleton() {
599        let old = Graph::new(1, false).unwrap();
600        let new_g = correlated_game(&old, 0.5, 0.5, None, 1).unwrap();
601        assert_eq!(new_g.vcount(), 1);
602        assert_eq!(new_g.ecount(), 0);
603    }
604
605    #[test]
606    fn corr_zero_independent_density() {
607        // corr=0 is just ER(n, p); should NOT preserve any edges except
608        // by chance.
609        let old = erdos_renyi_gnp(50, 0.5, false, false, 7).unwrap();
610        let new_g = correlated_game(&old, 0.0, 0.5, None, 11).unwrap();
611        assert_eq!(new_g.vcount(), old.vcount());
612        // Independent → expected overlap is p² · cap ≈ 0.25 · 1225 ≈ 306.
613        let overlap = edge_set(&old).intersection(&edge_set(&new_g)).count();
614        assert!(
615            (200..420).contains(&overlap),
616            "independent overlap should hover near 306, got {overlap}"
617        );
618    }
619
620    #[test]
621    fn corr_one_exact_copy() {
622        let old = erdos_renyi_gnp(40, 0.3, false, false, 5).unwrap();
623        let new_g = correlated_game(&old, 1.0, 0.3, None, 11).unwrap();
624        assert_eq!(new_g.vcount(), old.vcount());
625        assert_eq!(new_g.ecount(), old.ecount());
626        assert_eq!(edge_set(&new_g), edge_set(&old));
627    }
628
629    #[test]
630    fn corr_one_with_permutation_relabels() {
631        let old = erdos_renyi_gnp(10, 0.4, false, false, 3).unwrap();
632        // Reverse permutation: new[i] = old[n - 1 - i].
633        let n = old.vcount();
634        let perm: Vec<VertexId> = (0..n).rev().collect();
635        let new_g = correlated_game(&old, 1.0, 0.4, Some(&perm), 17).unwrap();
636        assert_eq!(new_g.ecount(), old.ecount());
637        // Every original (u, v) should appear as (n-1-u, n-1-v) in new.
638        let mut expected = std::collections::HashSet::new();
639        let m = u32::try_from(old.ecount()).unwrap();
640        for eid in 0..m {
641            let (f, t) = old.edge(eid).unwrap();
642            let (fn_, tn) = (n - 1 - f, n - 1 - t);
643            let key = if fn_ < tn { (fn_, tn) } else { (tn, fn_) };
644            expected.insert(key);
645        }
646        assert_eq!(edge_set(&new_g), expected);
647    }
648
649    #[test]
650    fn high_corr_preserves_most_edges() {
651        let old = erdos_renyi_gnp(100, 0.2, false, false, 23).unwrap();
652        let new_g = correlated_game(&old, 0.95, 0.2, None, 31).unwrap();
653        let intersection = edge_set(&old).intersection(&edge_set(&new_g)).count();
654        let frac = intersection as f64 / ecount_of(&old) as f64;
655        assert!(
656            frac > 0.85,
657            "corr=0.95 should preserve >85% of old edges, got {frac:.3}"
658        );
659    }
660
661    #[test]
662    fn low_corr_preserves_few_edges() {
663        let old = erdos_renyi_gnp(100, 0.2, false, false, 23).unwrap();
664        let new_g = correlated_game(&old, 0.1, 0.2, None, 31).unwrap();
665        let intersection = edge_set(&old).intersection(&edge_set(&new_g)).count();
666        let frac = intersection as f64 / ecount_of(&old) as f64;
667        // p²·cap = 0.04·4950 = 198 independent overlap; corr=0.1 lifts
668        // that mildly. Should still be well under 50%.
669        assert!(
670            frac < 0.45,
671            "corr=0.1 should preserve <45% of old edges, got {frac:.3}"
672        );
673    }
674
675    #[test]
676    fn output_is_simple_no_self_loops() {
677        let old = erdos_renyi_gnp(40, 0.3, false, false, 9).unwrap();
678        let new_g = correlated_game(&old, 0.5, 0.3, None, 13).unwrap();
679        assert_no_self_loops(&new_g);
680        assert_simple(&new_g);
681    }
682
683    #[test]
684    fn deterministic_same_seed() {
685        let old = erdos_renyi_gnp(30, 0.3, false, false, 1).unwrap();
686        let a = correlated_game(&old, 0.5, 0.3, None, 999).unwrap();
687        let b = correlated_game(&old, 0.5, 0.3, None, 999).unwrap();
688        assert_eq!(edge_set(&a), edge_set(&b));
689    }
690
691    #[test]
692    fn different_seed_different_output() {
693        let old = erdos_renyi_gnp(30, 0.3, false, false, 1).unwrap();
694        let a = correlated_game(&old, 0.5, 0.3, None, 999).unwrap();
695        let b = correlated_game(&old, 0.5, 0.3, None, 1000).unwrap();
696        assert_ne!(edge_set(&a), edge_set(&b));
697    }
698
699    #[test]
700    fn directed_input_yields_directed_output() {
701        let old = erdos_renyi_gnp(20, 0.3, true, false, 5).unwrap();
702        let new_g = correlated_game(&old, 0.5, 0.3, None, 7).unwrap();
703        assert!(new_g.is_directed());
704        assert_no_self_loops(&new_g);
705        assert_simple(&new_g);
706    }
707
708    #[test]
709    fn invalid_corr_rejected() {
710        let old = erdos_renyi_gnp(5, 0.3, false, false, 1).unwrap();
711        assert!(correlated_game(&old, -0.1, 0.3, None, 1).is_err());
712        assert!(correlated_game(&old, 1.1, 0.3, None, 1).is_err());
713        assert!(correlated_game(&old, f64::NAN, 0.3, None, 1).is_err());
714    }
715
716    #[test]
717    fn invalid_p_rejected() {
718        let old = erdos_renyi_gnp(5, 0.3, false, false, 1).unwrap();
719        assert!(correlated_game(&old, 0.5, 0.0, None, 1).is_err());
720        assert!(correlated_game(&old, 0.5, 1.0, None, 1).is_err());
721        assert!(correlated_game(&old, 0.5, -0.1, None, 1).is_err());
722        assert!(correlated_game(&old, 0.5, f64::NAN, None, 1).is_err());
723    }
724
725    #[test]
726    fn invalid_permutation_rejected() {
727        let old = erdos_renyi_gnp(5, 0.3, false, false, 1).unwrap();
728        // Wrong length.
729        let bad_len: Vec<VertexId> = vec![0, 1, 2];
730        assert!(correlated_game(&old, 0.5, 0.3, Some(&bad_len), 1).is_err());
731        // Duplicate entries.
732        let dup: Vec<VertexId> = vec![0, 1, 2, 1, 4];
733        assert!(correlated_game(&old, 0.5, 0.3, Some(&dup), 1).is_err());
734        // Out-of-range.
735        let oor: Vec<VertexId> = vec![0, 1, 2, 3, 99];
736        assert!(correlated_game(&old, 0.5, 0.3, Some(&oor), 1).is_err());
737    }
738
739    #[test]
740    fn non_simple_old_rejected() {
741        let mut old = Graph::new(3, false).unwrap();
742        old.add_edge(0, 1).unwrap();
743        old.add_edge(0, 1).unwrap(); // multi-edge
744        assert!(correlated_game(&old, 0.5, 0.3, None, 1).is_err());
745    }
746
747    #[test]
748    fn correlated_pair_basic() {
749        let (g1, g2) = correlated_pair_game(50, 0.8, 0.3, false, None, 42).unwrap();
750        assert_eq!(g1.vcount(), 50);
751        assert_eq!(g2.vcount(), 50);
752        assert_simple(&g1);
753        assert_simple(&g2);
754        // High correlation → overlap > independent baseline (p² · cap).
755        let cap = 50.0 * 49.0 / 2.0;
756        let baseline = 0.3_f64.powi(2) * cap;
757        let overlap = edge_set(&g1).intersection(&edge_set(&g2)).count() as f64;
758        assert!(
759            overlap > baseline * 1.5,
760            "high-corr overlap {overlap} should exceed baseline {baseline}"
761        );
762    }
763
764    #[test]
765    fn correlated_pair_directed() {
766        let (g1, g2) = correlated_pair_game(30, 0.5, 0.3, true, None, 7).unwrap();
767        assert!(g1.is_directed());
768        assert!(g2.is_directed());
769        assert_simple(&g1);
770        assert_simple(&g2);
771    }
772}
773
774#[cfg(all(test, feature = "proptest-harness"))]
775mod prop_tests {
776    use super::*;
777    use proptest::prelude::*;
778
779    proptest! {
780        #[test]
781        fn vcount_preserved(n in 2u32..40, p in 0.05f64..0.95, corr in 0.0f64..=1.0, seed in any::<u64>()) {
782            let old = erdos_renyi_gnp(n, p, false, false, seed).unwrap();
783            let new_g = correlated_game(&old, corr, p, None, seed.wrapping_add(1)).unwrap();
784            prop_assert_eq!(new_g.vcount(), n);
785            prop_assert_eq!(new_g.is_directed(), false);
786        }
787
788        #[test]
789        fn always_simple(n in 2u32..30, p in 0.1f64..0.9, corr in 0.0f64..=1.0, seed in any::<u64>()) {
790            let old = erdos_renyi_gnp(n, p, false, false, seed).unwrap();
791            let new_g = correlated_game(&old, corr, p, None, seed.wrapping_add(1)).unwrap();
792            prop_assert!(is_simple(&new_g).unwrap());
793            let m = u32::try_from(new_g.ecount()).unwrap();
794            for eid in 0..m {
795                let (f, t) = new_g.edge(eid).unwrap();
796                prop_assert_ne!(f, t);
797            }
798        }
799
800        #[test]
801        fn corr_one_is_exact_copy(n in 2u32..30, p in 0.1f64..0.9, seed in any::<u64>()) {
802            let old = erdos_renyi_gnp(n, p, false, false, seed).unwrap();
803            let new_g = correlated_game(&old, 1.0, p, None, seed.wrapping_add(1)).unwrap();
804            prop_assert_eq!(new_g.ecount(), old.ecount());
805        }
806
807        #[test]
808        fn deterministic(n in 2u32..30, p in 0.1f64..0.9, corr in 0.0f64..=1.0, seed in any::<u64>()) {
809            let old = erdos_renyi_gnp(n, p, false, false, seed).unwrap();
810            let a = correlated_game(&old, corr, p, None, seed.wrapping_add(1)).unwrap();
811            let b = correlated_game(&old, corr, p, None, seed.wrapping_add(1)).unwrap();
812            prop_assert_eq!(a.ecount(), b.ecount());
813        }
814
815        #[test]
816        fn directed_round_trip(n in 2u32..25, p in 0.1f64..0.9, corr in 0.0f64..=1.0, seed in any::<u64>()) {
817            let old = erdos_renyi_gnp(n, p, true, false, seed).unwrap();
818            let new_g = correlated_game(&old, corr, p, None, seed.wrapping_add(1)).unwrap();
819            prop_assert_eq!(new_g.vcount(), n);
820            prop_assert!(new_g.is_directed());
821            prop_assert!(is_simple(&new_g).unwrap());
822        }
823    }
824}