Skip to main content

rust_igraph/algorithms/games/
static_fitness.rs

1//! Static-fitness random graph generator (ALGO-GN-013).
2//!
3//! Counterpart of `igraph_static_fitness_game()` from
4//! `references/igraph/src/games/static_fitness.c:110` and the
5//! power-law shaping convenience `igraph_static_power_law_game()`
6//! from line 387 of the same file.
7//!
8//! The model (Goh–Kahng–Kim 2001) starts from `n` disconnected
9//! vertices, each carrying a non-negative *fitness* value. For each of
10//! the `m` desired edges:
11//!
12//! 1. Sample the source vertex `i` with probability proportional to
13//!    `fitness_out[i]` (cumulative-sum + binary search).
14//! 2. Sample the target vertex `j` with probability proportional to
15//!    `fitness_in[j]` (or `fitness_out[j]` when undirected).
16//! 3. Skip if it is a self-loop and `loops = false`. Skip and retry if
17//!    the edge already exists and `multiple = false`. Otherwise commit.
18//!
19//! The "no-multi" mode uses a per-source `HashSet` of accepted
20//! neighbours to detect duplicates in `O(1)` amortised — upstream uses
21//! per-vertex sorted vectors, which gives `O(log d_i)` lookups; both
22//! choices land at the same asymptotic `O(|V| + |E| log |E|)` overall.
23//!
24//! ## Two public entry points
25//!
26//! * [`static_fitness_game`] — the primitive: caller supplies the
27//!   per-vertex fitness vector(s) directly.
28//! * [`static_power_law_game`] — convenience that builds power-law
29//!   fitness vectors `i^α` (with optional Cho et al. finite-size
30//!   correction) and forwards to `static_fitness_game`.
31//!
32//! ## Difference from Chung–Lu (ALGO-GN-012)
33//!
34//! Chung–Lu fixes the **expected** degree of every vertex but the
35//! realised edge count fluctuates with the variant. Here we instead
36//! fix the **edge count** exactly at `m` and only the *shape* of the
37//! degree distribution is controlled by the fitness vector. The two
38//! samplers are also algorithmically distinct: Chung–Lu uses
39//! Miller–Hagberg geometric skip, this one uses cumulative-sum +
40//! binsearch.
41//!
42//! ## Determinism
43//!
44//! Reproducible given the inputs and `seed` against the shared
45//! `SplitMix64` PRNG. The stream is *not* portable
46//! to upstream igraph's GLIBC-style RNG, so conformance assertions are
47//! structural (vertex/edge counts, validation rules, expected
48//! degree-fitness correlation) rather than bit-exact.
49//!
50//! ## References
51//!
52//! * Goh K-I, Kahng B, Kim D: *Universal behaviour of load
53//!   distribution in scale-free networks*. Phys. Rev. Lett.
54//!   **87**(27):278701 (2001).
55//! * Chung F, Lu L: *Connected components in a random graph with given
56//!   degree sequences*. Annals of Combinatorics **6**, 125–145 (2002).
57//! * Cho YS, Kim JS, Park J, Kahng B, Kim D: *Percolation transitions
58//!   in scale-free networks under the Achlioptas process*. Phys. Rev.
59//!   Lett. **103**:135702 (2009).
60
61#![allow(
62    unknown_lints,
63    clippy::cast_possible_truncation,
64    clippy::cast_precision_loss,
65    clippy::cast_sign_loss,
66    clippy::float_cmp,
67    clippy::similar_names,
68    clippy::many_single_char_names
69)]
70
71use std::collections::HashSet;
72
73use crate::core::rng::SplitMix64;
74use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
75
76/// Largest vertex count we accept. Matches the `chung_lu` guard
77/// (`IGRAPH_MAX_EXACT_REAL == 2^53`), the largest integer exactly
78/// representable as `f64`. On 32-bit targets `usize::MAX < 2^53`, so
79/// the cap is effectively the address-space limit and the const stays
80/// valid across pointer widths.
81const MAX_NODES: usize = if usize::BITS >= 64 {
82    1usize.wrapping_shl(53)
83} else {
84    usize::MAX
85};
86
87fn check_fitness(label: &str, fitness: &[f64]) -> IgraphResult<()> {
88    let mut min_v = f64::INFINITY;
89    let mut max_v = f64::NEG_INFINITY;
90    for (i, &x) in fitness.iter().enumerate() {
91        if x.is_nan() {
92            return Err(IgraphError::InvalidArgument(format!(
93                "{label}[{i}] is NaN; static-fitness scores must be finite"
94            )));
95        }
96        if x < min_v {
97            min_v = x;
98        }
99        if x > max_v {
100            max_v = x;
101        }
102    }
103    if fitness.is_empty() {
104        return Ok(());
105    }
106    if min_v < 0.0 {
107        return Err(IgraphError::InvalidArgument(format!(
108            "{label} must be non-negative; got minimum {min_v}"
109        )));
110    }
111    if !max_v.is_finite() {
112        return Err(IgraphError::InvalidArgument(format!(
113            "{label} must be finite; got maximum {max_v}"
114        )));
115    }
116    Ok(())
117}
118
119/// Build the cumulative-sum vector. `cum[i] = Σ_{k=0..=i} fitness[k]`.
120fn cumulative_sum(fitness: &[f64]) -> Vec<f64> {
121    let mut cum = Vec::with_capacity(fitness.len());
122    let mut acc = 0.0_f64;
123    for &x in fitness {
124        acc += x;
125        cum.push(acc);
126    }
127    cum
128}
129
130/// Binary search: smallest `i` such that `cum[i] >= target`. Assumes
131/// `cum` is non-decreasing and `target ∈ [0, cum.last()]`.
132fn binsearch_cum(cum: &[f64], target: f64) -> usize {
133    // Standard branchless lower_bound for non-strict monotone arrays.
134    let mut lo = 0usize;
135    let mut hi = cum.len();
136    while lo < hi {
137        let mid = lo + (hi - lo) / 2;
138        if cum[mid] < target {
139            lo = mid + 1;
140        } else {
141            hi = mid;
142        }
143    }
144    if lo >= cum.len() { cum.len() - 1 } else { lo }
145}
146
147/// Count vertices with strictly positive fitness.
148fn count_active(fitness: &[f64]) -> usize {
149    fitness.iter().filter(|&&x| x > 0.0).count()
150}
151
152/// Compute the upper bound `max_no_of_edges` for the given fitness
153/// layout, mirroring the C reference. Returns the float-valued bound so
154/// callers can compare against the requested edge count without
155/// worrying about `n * (n - 1)` overflowing `usize` for huge `n`.
156fn max_edges(fitness_out: &[f64], fitness_in: Option<&[f64]>, loops: bool) -> f64 {
157    let n = fitness_out.len();
158    if n == 0 {
159        return 0.0;
160    }
161    if let Some(fin) = fitness_in {
162        // Directed: out-active × in-active, minus the diagonal when
163        // self-loops are forbidden (only the both-active diagonal slots
164        // disappear).
165        let mut outn = 0usize;
166        let mut inn = 0usize;
167        let mut bothn = 0usize;
168        for i in 0..n {
169            let o = fitness_out[i] != 0.0;
170            let ii = fin[i] != 0.0;
171            if o {
172                outn += 1;
173            }
174            if ii {
175                inn += 1;
176            }
177            if o && ii {
178                bothn += 1;
179            }
180        }
181        let prod = outn as f64 * inn as f64;
182        if loops { prod } else { prod - bothn as f64 }
183    } else {
184        // Undirected: choose 2 over active count, plus the diagonal
185        // when self-loops are allowed.
186        let active = count_active(fitness_out) as f64;
187        if loops {
188            active * (active + 1.0) / 2.0
189        } else {
190            active * (active - 1.0) / 2.0
191        }
192    }
193}
194
195/// Sample a random graph from the **static-fitness** model
196/// (Goh–Kahng–Kim 2001).
197///
198/// * `no_of_edges` — exact number of edges to generate.
199/// * `fitness_out` — non-negative, finite per-vertex fitness. Length
200///   determines the resulting graph's vertex count `n`. Higher values
201///   mean a vertex is more likely to be picked as the (out-)endpoint of
202///   each edge; the *expected* (out-)degree is proportional to the
203///   fitness, but unlike Chung–Lu the realised degrees fluctuate around
204///   that mean and the total edge count is fixed.
205/// * `fitness_in` — when `Some`, generates a **directed** graph with
206///   independent out- and in-fitness. Must have the same length as
207///   `fitness_out`. When `None`, generates an **undirected** graph.
208/// * `loops` — when `true`, self-loops are permitted (and may be
209///   sampled).
210/// * `multiple` — when `true`, parallel edges are permitted; when
211///   `false`, every accepted edge is unique.
212/// * `seed` — initialises an internal `SplitMix64` PRNG so the output
213///   is reproducible given the inputs.
214///
215/// # Errors
216///
217/// Returns [`IgraphError::InvalidArgument`] if:
218/// * any fitness value is negative, NaN, or non-finite (`±∞`);
219/// * `fitness_in` length differs from `fitness_out` length;
220/// * the upper-bound `max_no_of_edges` is zero but `no_of_edges > 0`
221///   (e.g. one-vertex graph without self-loops, or all-zero fitness);
222/// * `!multiple && no_of_edges > max_no_of_edges` (impossible to
223///   pack that many unique edges into the available slots);
224/// * the requested vertex count exceeds the `u32::MAX` bound on
225///   `VertexId` (or the more conservative `2^53` upper bound).
226///
227/// # Complexity
228///
229/// `O(|V| + |E| log |V|)` — the per-edge `log |V|` comes from the
230/// cumulative-fitness binary search; for `multiple = false` an extra
231/// `O(log d_i)` (here `O(1)` amortised via `HashSet`) per accepted edge
232/// is added for the duplicate check.
233///
234/// # Examples
235///
236/// ```
237/// use rust_igraph::static_fitness_game;
238///
239/// // Five vertices, decaying fitness, 8 undirected simple edges.
240/// let fitness = [5.0, 4.0, 3.0, 2.0, 1.0];
241/// let g = static_fitness_game(8, &fitness, None, false, false, 42).unwrap();
242/// assert_eq!(g.vcount(), 5);
243/// assert_eq!(g.ecount(), 8);
244/// assert!(!g.is_directed());
245/// ```
246pub fn static_fitness_game(
247    no_of_edges: u32,
248    fitness_out: &[f64],
249    fitness_in: Option<&[f64]>,
250    loops: bool,
251    multiple: bool,
252    seed: u64,
253) -> IgraphResult<Graph> {
254    let n = fitness_out.len();
255    let directed = fitness_in.is_some();
256
257    #[allow(clippy::absurd_extreme_comparisons)]
258    if n > MAX_NODES {
259        return Err(IgraphError::InvalidArgument(format!(
260            "static-fitness vertex count {n} exceeds the largest exactly representable f64 integer (2^53)"
261        )));
262    }
263
264    let n_u32 = u32::try_from(n).map_err(|_| {
265        IgraphError::InvalidArgument(format!("static-fitness vertex count {n} exceeds u32::MAX"))
266    })?;
267
268    // n == 0 is a degenerate but legitimate case in the C reference
269    // (returns an empty graph) — even when edges were requested. We
270    // mirror that for backwards compatibility with upstream test
271    // fixtures.
272    if n == 0 {
273        return Graph::new(0, directed);
274    }
275
276    if let Some(fin) = fitness_in {
277        if fin.len() != n {
278            return Err(IgraphError::InvalidArgument(format!(
279                "fitness_in length {} does not match fitness_out length {n}",
280                fin.len()
281            )));
282        }
283    }
284
285    check_fitness("fitness_out", fitness_out)?;
286    if let Some(fin) = fitness_in {
287        check_fitness("fitness_in", fin)?;
288    }
289
290    // Compute the upper bound on realisable edges and apply both the
291    // "no edges possible" and "too many edges" gates.
292    let max_e = max_edges(fitness_out, fitness_in, loops);
293    if max_e <= 0.0 && no_of_edges > 0 {
294        return Err(IgraphError::InvalidArgument(format!(
295            "No edges are possible with the given fitness scores, but {no_of_edges} edges were requested"
296        )));
297    }
298    if !multiple && f64::from(no_of_edges) > max_e {
299        return Err(IgraphError::InvalidArgument(format!(
300            "Requested {no_of_edges} simple edges but only {max_e} are possible with the given fitness scores"
301        )));
302    }
303
304    if no_of_edges == 0 {
305        return Graph::new(n_u32, directed);
306    }
307
308    // Build cumulative-sum vectors.
309    let cum_out = cumulative_sum(fitness_out);
310    let Some(&max_out) = cum_out.last() else {
311        return Err(IgraphError::Internal("cum_out unexpectedly empty"));
312    };
313    let (cum_in_storage, cum_in_view, max_in): (Vec<f64>, &[f64], f64) = match fitness_in {
314        Some(fin) => {
315            let c = cumulative_sum(fin);
316            let Some(&m) = c.last() else {
317                return Err(IgraphError::Internal("cum_in unexpectedly empty"));
318            };
319            (c, &[], m)
320        }
321        None => (Vec::new(), &cum_out, max_out),
322    };
323    // We can't bind a borrow to `cum_in_storage` and return `(_, &_)`
324    // in the same `let`, so do the borrow here.
325    let cum_in: &[f64] = if directed {
326        &cum_in_storage
327    } else {
328        cum_in_view
329    };
330
331    if max_out <= 0.0 || max_in <= 0.0 {
332        // Already covered by max_e == 0 above (since active count == 0
333        // ⇒ max_e == 0) but cheap to be defensive against numerical
334        // edge cases.
335        return Graph::new(n_u32, directed);
336    }
337
338    let mut rng = SplitMix64::new(seed);
339    let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(no_of_edges as usize);
340
341    if multiple {
342        // Trivial sample-and-keep loop.
343        let mut remaining = no_of_edges;
344        while remaining > 0 {
345            let from = pick(&cum_out, max_out, &mut rng);
346            let to = pick(cum_in, max_in, &mut rng);
347            if !loops && from == to {
348                continue;
349            }
350            edges.push((from as VertexId, to as VertexId));
351            remaining -= 1;
352        }
353    } else {
354        // Reject duplicates. For undirected graphs canonicalise to
355        // `(min, max)` so the same pair sampled from either direction
356        // collapses to one entry in the set.
357        let mut seen: HashSet<(u32, u32)> = HashSet::with_capacity(no_of_edges as usize);
358        let mut remaining = no_of_edges;
359        while remaining > 0 {
360            let from = pick(&cum_out, max_out, &mut rng);
361            let to = pick(cum_in, max_in, &mut rng);
362            if !loops && from == to {
363                continue;
364            }
365            let key = if directed || from <= to {
366                (from as u32, to as u32)
367            } else {
368                (to as u32, from as u32)
369            };
370            if !seen.insert(key) {
371                continue;
372            }
373            edges.push((from as VertexId, to as VertexId));
374            remaining -= 1;
375        }
376    }
377
378    let mut g = Graph::new(n_u32, directed)?;
379    g.add_edges(edges)?;
380    Ok(g)
381}
382
383#[inline]
384fn pick(cum: &[f64], total: f64, rng: &mut SplitMix64) -> usize {
385    let u = rng.gen_unit();
386    let target = u * total;
387    binsearch_cum(cum, target)
388}
389
390/// Sample a random graph whose degree distribution follows a
391/// **power law** of prescribed exponent(s) (Goh et al. 2001).
392///
393/// Builds the fitness vector `fitness[i] = j^α` with
394/// `j = n, n-1, …, 1` and `α = -1 / (exponent - 1)`, optionally with
395/// the Cho et al. (2009) finite-size correction shifting `j` upward,
396/// then delegates to [`static_fitness_game`].
397///
398/// * `no_of_nodes` — vertex count `n`.
399/// * `no_of_edges` — exact number of edges to generate.
400/// * `exponent_out` — power-law exponent for the (out-)degree
401///   distribution. Must be `>= 2`. Pass [`f64::INFINITY`] to recover an
402///   Erdős–Rényi sampler.
403/// * `exponent_in` — `Some(e)` selects a **directed** graph with an
404///   independent in-degree exponent (also `>= 2`); the in-fitness
405///   vector is randomly shuffled before sampling to decorrelate the
406///   in- and out-degree sequences. `None` selects an **undirected**
407///   graph.
408/// * `loops`, `multiple` — passed through to `static_fitness_game`.
409/// * `finite_size_correction` — when `true`, apply the Cho et al.
410///   shift on each exponent that satisfies `α < -0.5` (equivalently
411///   `exponent < 3`).
412/// * `seed` — initialises an internal `SplitMix64` PRNG so the
413///   output is reproducible given the inputs.
414///
415/// # Errors
416///
417/// Returns [`IgraphError::InvalidArgument`] if:
418/// * `exponent_out < 2` or `exponent_in = Some(e)` with `e < 2`;
419/// * `exponent_out` is NaN, or `exponent_in = Some(e)` with NaN;
420/// * any condition listed under [`static_fitness_game`] fires when
421///   forwarding the synthesised fitness vectors.
422///
423/// # Examples
424///
425/// ```
426/// use rust_igraph::static_power_law_game;
427///
428/// let g = static_power_law_game(50, 100, 2.5, None, false, true, true, 7).unwrap();
429/// assert_eq!(g.vcount(), 50);
430/// assert_eq!(g.ecount(), 100);
431/// assert!(!g.is_directed());
432/// ```
433#[allow(clippy::too_many_arguments)]
434pub fn static_power_law_game(
435    no_of_nodes: u32,
436    no_of_edges: u32,
437    exponent_out: f64,
438    exponent_in: Option<f64>,
439    loops: bool,
440    multiple: bool,
441    finite_size_correction: bool,
442    seed: u64,
443) -> IgraphResult<Graph> {
444    if exponent_out.is_nan() {
445        return Err(IgraphError::InvalidArgument(
446            "exponent_out must not be NaN".into(),
447        ));
448    }
449    if exponent_out < 2.0 {
450        return Err(IgraphError::InvalidArgument(format!(
451            "Out-degree exponent must be >= 2, got {exponent_out}"
452        )));
453    }
454    if let Some(e) = exponent_in {
455        if e.is_nan() {
456            return Err(IgraphError::InvalidArgument(
457                "exponent_in must not be NaN".into(),
458            ));
459        }
460        if e < 2.0 {
461            return Err(IgraphError::InvalidArgument(format!(
462                "For directed graphs the in-degree exponent must be >= 2, got {e}"
463            )));
464        }
465    }
466
467    let n = no_of_nodes as usize;
468    let directed = exponent_in.is_some();
469    if n == 0 {
470        return Graph::new(0, directed);
471    }
472
473    let fitness_out = build_power_law_fitness(n, exponent_out, finite_size_correction);
474
475    if let Some(e_in) = exponent_in {
476        let mut fitness_in = build_power_law_fitness(n, e_in, finite_size_correction);
477        // Decorrelate in- and out-fitnesses by Fisher–Yates shuffling
478        // the in-fitness vector — this is what upstream igraph does.
479        let mut shuf_rng = SplitMix64::new(seed.wrapping_add(0xDEAD_BEEF_CAFE_BABE));
480        fisher_yates(&mut fitness_in, &mut shuf_rng);
481        static_fitness_game(
482            no_of_edges,
483            &fitness_out,
484            Some(&fitness_in),
485            loops,
486            multiple,
487            seed,
488        )
489    } else {
490        static_fitness_game(no_of_edges, &fitness_out, None, loops, multiple, seed)
491    }
492}
493
494/// Build the fitness vector `j^α` with `j = n, n-1, …, 1`, optionally
495/// shifted up by the Cho et al. finite-size correction.
496fn build_power_law_fitness(n: usize, exponent: f64, finite_size_correction: bool) -> Vec<f64> {
497    let alpha = if exponent.is_finite() {
498        -1.0 / (exponent - 1.0)
499    } else {
500        0.0
501    };
502
503    let mut j = n as f64;
504    if finite_size_correction && alpha < -0.5 {
505        // Cho et al. (2009), first page first column + footnote 7.
506        let shift = (n as f64).powf(1.0 + 0.5 / alpha)
507            * (10.0 * std::f64::consts::SQRT_2 * (1.0 + alpha)).powf(-1.0 / alpha)
508            - 1.0;
509        j += shift;
510    }
511    if j < n as f64 {
512        j = n as f64;
513    }
514
515    let mut fitness = Vec::with_capacity(n);
516    for _ in 0..n {
517        fitness.push(j.powf(alpha));
518        j -= 1.0;
519    }
520    fitness
521}
522
523/// In-place Fisher–Yates shuffle using the supplied PRNG.
524fn fisher_yates(v: &mut [f64], rng: &mut SplitMix64) {
525    let n = v.len();
526    if n <= 1 {
527        return;
528    }
529    for i in (1..n).rev() {
530        let j = rng.gen_index(i + 1);
531        v.swap(i, j);
532    }
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538
539    fn deg(g: &Graph, n: usize) -> Vec<u32> {
540        let mut d = vec![0u32; n];
541        let m = u32::try_from(g.ecount()).expect("ecount fits in u32");
542        for eid in 0..m {
543            let (u, v) = g.edge(eid).expect("edge id in bounds");
544            d[u as usize] += 1;
545            if u != v {
546                d[v as usize] += 1;
547            }
548        }
549        d
550    }
551
552    fn directed_in_out(g: &Graph, n: usize) -> (Vec<u32>, Vec<u32>) {
553        let mut out = vec![0u32; n];
554        let mut din = vec![0u32; n];
555        let m = u32::try_from(g.ecount()).expect("ecount fits in u32");
556        for eid in 0..m {
557            let (u, v) = g.edge(eid).expect("edge id in bounds");
558            out[u as usize] += 1;
559            din[v as usize] += 1;
560        }
561        (out, din)
562    }
563
564    // --- empty / degenerate cases -----------------------------------
565
566    #[test]
567    fn zero_vertices_zero_edges_undirected() {
568        let g = static_fitness_game(0, &[], None, false, false, 0).unwrap();
569        assert_eq!(g.vcount(), 0);
570        assert_eq!(g.ecount(), 0);
571        assert!(!g.is_directed());
572    }
573
574    #[test]
575    fn zero_vertices_zero_edges_directed() {
576        let in_w: [f64; 0] = [];
577        let g = static_fitness_game(0, &[], Some(&in_w), true, true, 0).unwrap();
578        assert_eq!(g.vcount(), 0);
579        assert!(g.is_directed());
580    }
581
582    #[test]
583    fn zero_edges_returns_isolated_graph() {
584        let f = vec![1.0; 5];
585        let g = static_fitness_game(0, &f, None, false, false, 0).unwrap();
586        assert_eq!(g.vcount(), 5);
587        assert_eq!(g.ecount(), 0);
588    }
589
590    #[test]
591    fn single_vertex_no_loops_zero_edges_ok() {
592        let g = static_fitness_game(0, &[1.0], None, false, false, 0).unwrap();
593        assert_eq!(g.vcount(), 1);
594        assert_eq!(g.ecount(), 0);
595    }
596
597    #[test]
598    fn single_vertex_no_loops_with_edges_errors() {
599        let err = static_fitness_game(1, &[1.0], None, false, false, 0).unwrap_err();
600        let msg = format!("{err:?}");
601        assert!(msg.contains("No edges are possible"), "{msg}");
602    }
603
604    #[test]
605    fn single_vertex_with_loops_works() {
606        let g = static_fitness_game(3, &[1.0], None, true, true, 0).unwrap();
607        assert_eq!(g.vcount(), 1);
608        assert_eq!(g.ecount(), 3);
609    }
610
611    // --- validation -------------------------------------------------
612
613    #[test]
614    fn negative_fitness_rejected() {
615        let f = [1.0, -0.1, 2.0];
616        let err = static_fitness_game(1, &f, None, false, true, 0).unwrap_err();
617        assert!(format!("{err:?}").contains("non-negative"));
618    }
619
620    #[test]
621    fn nan_fitness_rejected() {
622        let f = [1.0, f64::NAN, 2.0];
623        let err = static_fitness_game(1, &f, None, false, true, 0).unwrap_err();
624        assert!(format!("{err:?}").contains("NaN"));
625    }
626
627    #[test]
628    fn infinite_fitness_rejected() {
629        let f = [1.0, f64::INFINITY, 2.0];
630        let err = static_fitness_game(1, &f, None, false, true, 0).unwrap_err();
631        assert!(format!("{err:?}").contains("finite"));
632    }
633
634    #[test]
635    fn fitness_in_length_mismatch_rejected() {
636        let fo = [1.0, 2.0, 3.0];
637        let fi = [1.0, 2.0];
638        let err = static_fitness_game(1, &fo, Some(&fi), false, true, 0).unwrap_err();
639        assert!(format!("{err:?}").contains("length"));
640    }
641
642    #[test]
643    fn too_many_simple_edges_rejected() {
644        let f = vec![1.0; 4];
645        // n=4 undirected simple no-loops ⇒ max 6 edges; ask for 7.
646        let err = static_fitness_game(7, &f, None, false, false, 0).unwrap_err();
647        assert!(format!("{err:?}").contains("simple edges"));
648    }
649
650    #[test]
651    fn many_multi_edges_accepted() {
652        let f = vec![1.0; 4];
653        // With multiple=true the cap does NOT apply.
654        let g = static_fitness_game(20, &f, None, false, true, 0).unwrap();
655        assert_eq!(g.ecount(), 20);
656    }
657
658    // --- structural invariants --------------------------------------
659
660    #[test]
661    fn exact_edge_count_simple() {
662        let f = vec![1.0, 2.0, 3.0, 4.0, 5.0];
663        let g = static_fitness_game(7, &f, None, false, false, 11).unwrap();
664        assert_eq!(g.vcount(), 5);
665        assert_eq!(g.ecount(), 7);
666        assert!(!g.is_directed());
667    }
668
669    #[test]
670    fn no_self_loops_when_loops_false() {
671        let f = vec![1.0; 10];
672        let g = static_fitness_game(15, &f, None, false, true, 12).unwrap();
673        let m = u32::try_from(g.ecount()).unwrap();
674        for eid in 0..m {
675            let (u, v) = g.edge(eid).unwrap();
676            assert_ne!(u, v, "self-loop in edge {eid}");
677        }
678    }
679
680    #[test]
681    fn directed_round_trip_preserves_endpoints() {
682        let fo = vec![1.0, 2.0, 3.0, 4.0];
683        let fi = vec![3.0, 1.0, 2.0, 4.0];
684        let g = static_fitness_game(10, &fo, Some(&fi), false, true, 33).unwrap();
685        assert!(g.is_directed());
686        assert_eq!(g.ecount(), 10);
687        // Total out-degree == ecount.
688        let (out, din) = directed_in_out(&g, 4);
689        assert_eq!(out.iter().sum::<u32>(), 10);
690        assert_eq!(din.iter().sum::<u32>(), 10);
691    }
692
693    #[test]
694    fn determinism_same_seed_same_graph() {
695        let f = vec![1.0, 2.0, 3.0, 4.0, 5.0];
696        let g1 = static_fitness_game(8, &f, None, false, true, 99).unwrap();
697        let g2 = static_fitness_game(8, &f, None, false, true, 99).unwrap();
698        let m = u32::try_from(g1.ecount()).unwrap();
699        for eid in 0..m {
700            assert_eq!(g1.edge(eid).unwrap(), g2.edge(eid).unwrap());
701        }
702    }
703
704    #[test]
705    fn determinism_different_seed_differs() {
706        let f = vec![1.0, 2.0, 3.0, 4.0, 5.0];
707        let g1 = static_fitness_game(8, &f, None, false, true, 1).unwrap();
708        let g2 = static_fitness_game(8, &f, None, false, true, 2).unwrap();
709        let mut e1: Vec<_> = (0..u32::try_from(g1.ecount()).unwrap())
710            .map(|e| g1.edge(e).unwrap())
711            .collect();
712        let mut e2: Vec<_> = (0..u32::try_from(g2.ecount()).unwrap())
713            .map(|e| g2.edge(e).unwrap())
714            .collect();
715        e1.sort_unstable();
716        e2.sort_unstable();
717        assert_ne!(e1, e2);
718    }
719
720    #[test]
721    fn degree_correlates_with_fitness() {
722        // Statistical: with a wide fitness spread, the top-fitness
723        // vertex should out-degree the bottom-fitness vertex with high
724        // probability over a non-trivial number of edges.
725        let n = 50;
726        let fitness: Vec<f64> = (0..n).map(|i| 1.0 + (i as f64)).collect();
727        let g = static_fitness_game(400, &fitness, None, false, true, 0x00C0_FFEE).unwrap();
728        let d = deg(&g, n);
729        // Mean of top quartile vs mean of bottom quartile.
730        let q = n / 4;
731        let bot: f64 = (0..q).map(|i| f64::from(d[i])).sum::<f64>() / (q as f64);
732        let top: f64 = (n - q..n).map(|i| f64::from(d[i])).sum::<f64>() / (q as f64);
733        assert!(
734            top > 2.0 * bot,
735            "top-quartile mean degree {top} should dominate bottom {bot}"
736        );
737    }
738
739    // --- power-law wrapper ------------------------------------------
740
741    #[test]
742    fn power_law_basic_undirected() {
743        let g = static_power_law_game(100, 30, 2.0, None, true, true, true, 42).unwrap();
744        assert_eq!(g.vcount(), 100);
745        assert_eq!(g.ecount(), 30);
746        assert!(!g.is_directed());
747    }
748
749    #[test]
750    fn power_law_basic_directed() {
751        let g = static_power_law_game(100, 30, 2.0, Some(2.0), true, true, true, 42).unwrap();
752        assert_eq!(g.vcount(), 100);
753        assert_eq!(g.ecount(), 30);
754        assert!(g.is_directed());
755    }
756
757    #[test]
758    fn power_law_with_only_loops_simple() {
759        let g = static_power_law_game(90, 40, 2.0, None, true, false, true, 7).unwrap();
760        assert_eq!(g.vcount(), 90);
761        assert_eq!(g.ecount(), 40);
762    }
763
764    #[test]
765    fn power_law_with_only_multi() {
766        let g = static_power_law_game(110, 50, 2.0, None, false, true, true, 8).unwrap();
767        assert_eq!(g.vcount(), 110);
768        assert_eq!(g.ecount(), 50);
769    }
770
771    #[test]
772    fn power_law_zero_nodes() {
773        let g = static_power_law_game(0, 0, 2.0, Some(2.0), false, false, true, 0).unwrap();
774        assert_eq!(g.vcount(), 0);
775        assert!(g.is_directed());
776    }
777
778    #[test]
779    fn power_law_zero_edges_undirected() {
780        let g = static_power_law_game(10, 0, 2.0, None, false, false, true, 0).unwrap();
781        assert_eq!(g.vcount(), 10);
782        assert_eq!(g.ecount(), 0);
783        assert!(!g.is_directed());
784    }
785
786    #[test]
787    fn power_law_infinite_exponent_yields_uniform_fitness() {
788        // exponent = ∞ ⇒ α = 0 ⇒ fitness ≡ 1.0 across all vertices ⇒
789        // sampling reduces to picking uniformly random vertex pairs
790        // (Erdős–Rényi limit).
791        let fitness = build_power_law_fitness(20, f64::INFINITY, false);
792        for &f in &fitness {
793            assert!((f - 1.0).abs() < 1e-12);
794        }
795    }
796
797    #[test]
798    fn power_law_exponent_too_low_rejected() {
799        let err = static_power_law_game(100, 30, 1.5, None, true, true, true, 0).unwrap_err();
800        assert!(format!("{err:?}").contains(">= 2"));
801    }
802
803    #[test]
804    fn power_law_in_exponent_too_low_rejected() {
805        let err = static_power_law_game(100, 30, 2.0, Some(0.5), true, true, true, 0).unwrap_err();
806        assert!(format!("{err:?}").contains(">= 2"));
807    }
808
809    #[test]
810    fn power_law_nan_exponent_rejected() {
811        let err = static_power_law_game(100, 30, f64::NAN, None, true, true, true, 0).unwrap_err();
812        assert!(format!("{err:?}").contains("NaN"));
813    }
814
815    // --- internal helpers -------------------------------------------
816
817    #[test]
818    fn binsearch_cum_finds_first_ge() {
819        let cum = vec![1.0, 3.0, 6.0, 10.0];
820        assert_eq!(binsearch_cum(&cum, 0.0), 0);
821        assert_eq!(binsearch_cum(&cum, 0.5), 0);
822        assert_eq!(binsearch_cum(&cum, 1.0), 0);
823        assert_eq!(binsearch_cum(&cum, 1.5), 1);
824        assert_eq!(binsearch_cum(&cum, 3.0), 1);
825        assert_eq!(binsearch_cum(&cum, 5.5), 2);
826        assert_eq!(binsearch_cum(&cum, 10.0), 3);
827    }
828
829    #[test]
830    fn cumulative_sum_basic() {
831        let v = vec![1.0, 2.0, 3.0];
832        let c = cumulative_sum(&v);
833        assert_eq!(c, vec![1.0, 3.0, 6.0]);
834    }
835
836    #[test]
837    fn max_edges_undirected_no_loops() {
838        let f = vec![1.0; 4];
839        let m = max_edges(&f, None, false);
840        assert!((m - 6.0).abs() < 1e-12);
841    }
842
843    #[test]
844    fn max_edges_undirected_loops() {
845        let f = vec![1.0; 4];
846        let m = max_edges(&f, None, true);
847        assert!((m - 10.0).abs() < 1e-12);
848    }
849
850    #[test]
851    fn max_edges_directed_no_loops() {
852        let fo = vec![1.0; 4];
853        let fi = vec![1.0; 4];
854        let m = max_edges(&fo, Some(&fi), false);
855        assert!((m - 12.0).abs() < 1e-12);
856    }
857}
858
859#[cfg(all(test, feature = "proptest-harness"))]
860mod proptests {
861    use super::*;
862    use proptest::prelude::*;
863
864    proptest! {
865        #![proptest_config(ProptestConfig {
866            cases: 64,
867            ..ProptestConfig::default()
868        })]
869
870        // Invariant: realised edge count always equals the request when
871        // the request is feasible.
872        #[test]
873        fn fitness_exact_edge_count(
874            n in 2usize..30,
875            seed in any::<u64>(),
876            edges in 1u32..20,
877            multiple in any::<bool>(),
878            loops in any::<bool>(),
879        ) {
880            let f: Vec<f64> = (0..n).map(|i| 1.0 + i as f64).collect();
881            // Cap the request to the simple-graph upper bound when
882            // multiple=false to keep the example feasible.
883            let cap = if loops {
884                (n * (n + 1) / 2) as u32
885            } else {
886                (n * (n - 1) / 2) as u32
887            };
888            let m = if multiple { edges } else { edges.min(cap.max(1)) };
889            if !multiple && cap == 0 {
890                return Ok(());
891            }
892            let g = static_fitness_game(m, &f, None, loops, multiple, seed).unwrap();
893            prop_assert_eq!(g.vcount(), n as u32);
894            prop_assert_eq!(g.ecount(), m as usize);
895        }
896
897        // Invariant: no self-loops appear when loops=false.
898        #[test]
899        fn fitness_no_loops_when_disabled(
900            n in 3usize..20,
901            seed in any::<u64>(),
902            edges in 1u32..15,
903        ) {
904            let f: Vec<f64> = (0..n).map(|i| 1.0 + i as f64).collect();
905            let cap = (n * (n - 1) / 2) as u32;
906            let m = edges.min(cap);
907            let g = static_fitness_game(m, &f, None, false, true, seed).unwrap();
908            let me = u32::try_from(g.ecount()).unwrap();
909            for eid in 0..me {
910                let (u, v) = g.edge(eid).unwrap();
911                prop_assert_ne!(u, v);
912            }
913        }
914
915        // Invariant: power-law wrapper hits the exact edge count.
916        #[test]
917        fn power_law_exact_edge_count(
918            n in 5u32..60,
919            edges in 1u32..40,
920            seed in any::<u64>(),
921        ) {
922            let g = static_power_law_game(n, edges, 2.5, None, true, true, true, seed).unwrap();
923            prop_assert_eq!(g.vcount(), n);
924            prop_assert_eq!(g.ecount(), edges as usize);
925        }
926    }
927}