Skip to main content

rust_igraph/algorithms/games/
recent_degree_aging.rs

1//! Recent-degree preferential attachment with vertex aging
2//! (ALGO-GN-032).
3//!
4//! Counterpart of `igraph_recent_degree_aging_game()` in
5//! `references/igraph/src/games/recent_degree.c` (lines 228-381).
6//!
7//! Each step `i ≥ 1` adds a fresh vertex and attaches `m`
8//! (or `outseq[i]`) outgoing edges. Targets are drawn from a Fenwick BIT
9//! (`PsumTree`) weighted by a product of a *recent*-degree term and an
10//! age term:
11//!
12//! ```text
13//! weight(v) = (pow(recent_deg(v), pa_exp) + zero_appeal)
14//!           * pow(age(v), aging_exp)
15//! ```
16//!
17//! where `recent_deg(v)` counts edges received from `v` in the last
18//! `time_window` steps (sliding window), and
19//! `age(v) = (i − v) / binwidth + 1` with
20//! `binwidth = nodes / aging_bins + 1`.
21//!
22//! ## Hybrid of GN-019 and GN-021
23//!
24//! This model combines:
25//! * **GN-019 `recent_degree_game`** — the sliding-window FIFO over
26//!   `(vertex, step)` pairs that decrements counters on expiry and a
27//!   per-step sentinel separator.
28//! * **GN-021 `barabasi_aging_game`** — the binwidth-based age
29//!   computation and the periodic age-sweep (every `k · binwidth ≤ i`).
30//!
31//! The C source uses a slightly simpler weight formula than the
32//! barabasi-aging variant: only **one** zero term (`zero_appeal`) added
33//! to the degree component, no separate `zero_age_appeal`, `deg_coef`,
34//! or `age_coef`. The age term is `pow(age, aging_exp)` directly with no
35//! shift in coefficient.
36//!
37//! ## Mechanics (mirrors C lines 305-369)
38//!
39//! For each step `i` from `1` to `nodes − 1`:
40//!
41//! 1. **Window expiry** (only when `i ≥ time_window`): pop entries from
42//!    the front of `history` until a sentinel `None` is popped. For
43//!    each popped vertex `j`, decrement `degree[j]` and refresh
44//!    `psumtree[j]` with the NEW degree but the **age computed at the
45//!    moment of expiry** — matching the C source's
46//!    `(pow(deg, pa_exp) + zero_appeal) * pow((i - j)/binwidth + 1, aging_exp)`.
47//! 2. **Draw** `m` (or `outseq[i]`) targets, each via
48//!    `psumtree.search_bounded(uniform(0, sum), i)` — with a uniform
49//!    fallback over `[0, i)` when `sum == 0`.
50//! 3. For each chosen target: bump `degree[to]`, push `Some(to)` to
51//!    `history`, record edge `(i, to)`.
52//! 4. Push sentinel `None` to `history`.
53//! 5. **Refresh** psumtree entries for each chosen target with their new
54//!    weight (recent-degree + age).
55//! 6. **Insert vertex `i`**: if `outpref`, bump `degree[i] +=
56//!    no_of_neighbors` and refresh `psumtree[i]`; otherwise set it to
57//!    `zero_appeal`.
58//!    Note that `outpref` contributions to `degree[i]` are **permanent**
59//!    — they are NOT pushed to the history deque (matching C lines
60//!    349-356 which update degree+psumtree but not history).
61//! 7. **Age sweep**: for every `k ≥ 1` with `k · binwidth ≤ i`, refresh
62//!    vertex `i − k · binwidth` with age `k + 2`.
63//!
64//! ## Parameters
65//!
66//! * `nodes` — vertex count.
67//! * `m` — per-step out-degree when `outseq` is `None`.
68//! * `outseq` — optional length-`nodes` vector of per-step out-degrees.
69//!   The first entry is ignored (vertex 0 has no preceding vertices to
70//!   cite), matching the upstream contract.
71//! * `outpref` — when `true`, the new vertex's own emitted citations
72//!   feed back into its recent-degree counter (so its self-weight in the
73//!   BIT is non-zero immediately).
74//! * `pa_exp` — preferential-attachment exponent on recent degree.
75//! * `aging_exp` — aging exponent on age bin (usually negative —
76//!   younger vertices are favoured).
77//! * `aging_bins` — must be `≥ 1`. Sets `binwidth = nodes / aging_bins + 1`.
78//! * `time_window` — sliding-window length. `0` means the window is
79//!   always expired, so every vertex's recent degree decays to zero
80//!   immediately on the next step — effectively a pure-aging model with
81//!   `zero_appeal` floor.
82//! * `zero_appeal` — additive degree term so zero-recent-degree
83//!   vertices have non-zero weight (when `aging_exp` is non-negative).
84//!   Must be non-negative.
85//! * `directed` — emit directed edges (newer → older convention).
86//! * `seed` — initialises an internal `SplitMix64`.
87//!
88//! ## Construction guarantees
89//!
90//! * **No self-loops** by construction — the new vertex `i` is added to
91//!   the BIT *after* its `m` outgoing draws, and `search_bounded(_, i)`
92//!   prevents binary-lifting from over-advancing to position `i`.
93//! * **Multi-edges allowed** when `m ≥ 2` — independent draws against
94//!   the same snapshot of the BIT can collide. Matches upstream's
95//!   behaviour (no within-step zeroing).
96//! * **Zero-sum fallback**: when the BIT total is zero, the draw falls
97//!   back to a uniform sample over `[0, i)`. This fires when all current
98//!   vertices have decayed to zero weight (rare, but possible with very
99//!   small `time_window` and `zero_appeal = 0`).
100//!
101//! ## Cost
102//!
103//! `O((|V| + |V|/aging_bins + |E|) · log |V|)` per igraph C documentation:
104//! BIT operations dominate, while sliding-window expiry is amortised
105//! `O(|E|)` because each vertex is inserted into `history` exactly once
106//! and popped at most once.
107//!
108//! ## Notes on the C special case
109//!
110//! `weight_from_degree` here mirrors C's `pow(degree, pa_exp) + zero_appeal`,
111//! preserving `pow(0, 0) == 1` (degree=0 with `pa_exp=0` ⇒ degree term = 1).
112
113#![allow(
114    clippy::cast_possible_truncation,
115    clippy::cast_sign_loss,
116    clippy::cast_precision_loss,
117    clippy::cast_lossless,
118    clippy::too_many_arguments,
119    clippy::too_many_lines,
120    clippy::needless_range_loop,
121    clippy::similar_names,
122    clippy::many_single_char_names
123)]
124
125use std::collections::VecDeque;
126
127use crate::core::rng::SplitMix64;
128use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
129
130/// Fenwick-tree-based prefix-sum store with O(log n) point set and
131/// O(log n) prefix-target search bounded to `[0, bound)`.
132///
133/// Duplicated from sibling modules so this file is self-contained
134/// (matches the project's per-module BIT precedent set by
135/// `barabasi_psumtree.rs`, `barabasi_aging.rs`, and `recent_degree.rs`).
136struct PsumTree {
137    n: usize,
138    bit: Vec<f64>,
139    values: Vec<f64>,
140    total: f64,
141}
142
143impl PsumTree {
144    fn new(n: usize) -> Self {
145        Self {
146            n,
147            bit: vec![0.0; n + 1],
148            values: vec![0.0; n],
149            total: 0.0,
150        }
151    }
152
153    fn set(&mut self, i: usize, v: f64) {
154        let delta = v - self.values[i];
155        self.values[i] = v;
156        self.total += delta;
157        let mut k = i + 1;
158        while k <= self.n {
159            self.bit[k] += delta;
160            k += k & k.wrapping_neg();
161        }
162    }
163
164    fn total(&self) -> f64 {
165        self.total
166    }
167
168    /// Binary-lifted prefix-sum search constrained to `[0, bound)`.
169    fn search_bounded(&self, target: f64, bound: usize) -> usize {
170        if bound == 0 {
171            return 0;
172        }
173        let mut idx: usize = 0;
174        let mut remaining = target;
175        let mut step = 1usize;
176        while step.saturating_mul(2) <= bound {
177            step *= 2;
178        }
179        while step > 0 {
180            let next = idx + step;
181            if next <= bound && self.bit[next] <= remaining {
182                idx = next;
183                remaining -= self.bit[next];
184            }
185            step >>= 1;
186        }
187        idx.min(bound - 1)
188    }
189}
190
191/// History deque element. `Some(v)` marks a citation that contributed
192/// `+1` to `degree[v]`; `None` is the per-step sentinel.
193type HistoryEntry = Option<u32>;
194
195/// Mirrors C's `pow(VECTOR(degree)[j], pa_exp) + zero_appeal` term.
196/// Preserves the `pow(0, 0) == 1` convention.
197fn degree_term(degree: u32, pa_exp: f64, zero_appeal: f64) -> f64 {
198    let term = if degree == 0 {
199        if pa_exp == 0.0 {
200            1.0
201        } else if pa_exp > 0.0 {
202            0.0
203        } else {
204            f64::INFINITY
205        }
206    } else {
207        f64::from(degree).powf(pa_exp)
208    };
209    term + zero_appeal
210}
211
212/// Mirrors C's `pow(age, aging_exp)` term. `age` is always `≥ 1` in our
213/// caller (always shifted by `+1` or `+2`), but we guard the zero case
214/// anyway.
215fn age_term(age: u32, aging_exp: f64) -> f64 {
216    if age == 0 {
217        if aging_exp == 0.0 {
218            1.0
219        } else if aging_exp > 0.0 {
220            0.0
221        } else {
222            f64::INFINITY
223        }
224    } else {
225        f64::from(age).powf(aging_exp)
226    }
227}
228
229fn weight(degree: u32, age: u32, pa_exp: f64, aging_exp: f64, zero_appeal: f64) -> f64 {
230    degree_term(degree, pa_exp, zero_appeal) * age_term(age, aging_exp)
231}
232
233fn validate(
234    nodes: u32,
235    m: u32,
236    outseq: Option<&[u32]>,
237    pa_exp: f64,
238    aging_exp: f64,
239    aging_bins: u32,
240    zero_appeal: f64,
241) -> IgraphResult<()> {
242    if !pa_exp.is_finite() {
243        return Err(IgraphError::InvalidArgument(format!(
244            "pa_exp must be finite (got {pa_exp})"
245        )));
246    }
247    if !aging_exp.is_finite() {
248        return Err(IgraphError::InvalidArgument(format!(
249            "aging_exp must be finite (got {aging_exp})"
250        )));
251    }
252    if aging_bins == 0 {
253        return Err(IgraphError::InvalidArgument(
254            "aging_bins must be positive (got 0)".into(),
255        ));
256    }
257    if !zero_appeal.is_finite() || zero_appeal < 0.0 {
258        return Err(IgraphError::InvalidArgument(format!(
259            "zero_appeal must be finite and non-negative (got {zero_appeal})"
260        )));
261    }
262    if let Some(seq) = outseq {
263        if seq.len() != nodes as usize {
264            return Err(IgraphError::InvalidArgument(format!(
265                "outseq length ({}) must equal nodes ({nodes})",
266                seq.len()
267            )));
268        }
269    }
270    let _ = m;
271    Ok(())
272}
273
274fn edge_capacity(nodes: u32, m: u32, outseq: Option<&[u32]>) -> usize {
275    let n = nodes as usize;
276    match outseq {
277        Some(seq) => seq.iter().skip(1).map(|&x| x as usize).sum(),
278        None => n.saturating_sub(1).saturating_mul(m as usize),
279    }
280}
281
282/// Recent-degree preferential attachment with vertex aging.
283///
284/// See [module docs](self) for the full contract.
285///
286/// # Errors
287///
288/// * `pa_exp` / `aging_exp` not finite.
289/// * `aging_bins == 0`.
290/// * `zero_appeal` negative or non-finite.
291/// * `outseq.len() != nodes`.
292///
293/// # Examples
294///
295/// ```
296/// use rust_igraph::recent_degree_aging_game;
297///
298/// // 100-vertex directed graph, m=2 per step, no aging effect
299/// // (aging_exp=0 makes the age term identically 1), 5-step window.
300/// let g = recent_degree_aging_game(
301///     100, 2, None, false,
302///     1.0, 0.0, 10, 5,
303///     1.0, true, 0x42,
304/// ).unwrap();
305/// assert_eq!(g.vcount(), 100);
306/// assert_eq!(g.ecount(), 99 * 2);
307/// ```
308pub fn recent_degree_aging_game(
309    nodes: u32,
310    m: u32,
311    outseq: Option<&[u32]>,
312    outpref: bool,
313    pa_exp: f64,
314    aging_exp: f64,
315    aging_bins: u32,
316    time_window: u32,
317    zero_appeal: f64,
318    directed: bool,
319    seed: u64,
320) -> IgraphResult<Graph> {
321    validate(nodes, m, outseq, pa_exp, aging_exp, aging_bins, zero_appeal)?;
322
323    let mut graph = Graph::new(nodes, directed)?;
324    if nodes < 2 {
325        return Ok(graph);
326    }
327    if outseq.is_none() && m == 0 {
328        return Ok(graph);
329    }
330    if let Some(seq) = outseq {
331        let after_zero: u64 = seq.iter().skip(1).map(|&x| u64::from(x)).sum();
332        if after_zero == 0 {
333            return Ok(graph);
334        }
335    }
336
337    let n = nodes as usize;
338    let binwidth = (n / aging_bins as usize) + 1;
339    let mut rng = SplitMix64::new(seed);
340    let mut psum = PsumTree::new(n);
341    let mut degree: Vec<u32> = vec![0; n];
342    let mut history: VecDeque<HistoryEntry> = VecDeque::new();
343    let capacity = edge_capacity(nodes, m, outseq);
344    let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(capacity);
345
346    // Step 0: seed vertex enters with weight `zero_appeal * age_term(1)`.
347    // Matches C line 301: `igraph_psumtree_update(&sumtree, 0, zero_appeal)`.
348    // Note: C uses bare `zero_appeal` (not multiplied by an age term) for
349    // the seed vertex's initial weight — we match that exactly.
350    psum.set(0, zero_appeal);
351    history.push_back(None); // sentinel for step 0
352
353    for i in 1..n {
354        let no_of_neighbors = if let Some(seq) = outseq {
355            seq[i] as usize
356        } else {
357            m as usize
358        };
359
360        // 1. Expire window: pop history entries from the front until the
361        // per-step sentinel `None` is popped. For each popped vertex,
362        // decrement degree and refresh weight with the CURRENT age.
363        if i >= time_window as usize {
364            while let Some(entry) = history.pop_front() {
365                match entry {
366                    None => break,
367                    Some(j) => {
368                        let ju = j as usize;
369                        degree[ju] = degree[ju].saturating_sub(1);
370                        let age = ((i - ju) / binwidth + 1) as u32;
371                        let w = weight(degree[ju], age, pa_exp, aging_exp, zero_appeal);
372                        psum.set(ju, w);
373                    }
374                }
375            }
376        }
377
378        // 2-3. Draw `no_of_neighbors` targets and record edges.
379        // The C source samples against the live BIT each time (without
380        // zeroing picks within the step). We mirror that — the inner BIT
381        // total only changes if a chosen vertex's weight was different
382        // before; for safety we re-read total each draw.
383        let src =
384            u32::try_from(i).map_err(|_| IgraphError::Internal("vertex index overflows u32"))?;
385        for _ in 0..no_of_neighbors {
386            let sum = psum.total();
387            let to = if sum > 0.0 {
388                let target = rng.gen_unit() * sum;
389                psum.search_bounded(target, i)
390            } else {
391                let span = i as u64;
392                (rng.next_u64() % span) as usize
393            };
394            let dst = u32::try_from(to)
395                .map_err(|_| IgraphError::Internal("vertex index overflows u32"))?;
396            degree[to] = degree[to]
397                .checked_add(1)
398                .ok_or_else(|| IgraphError::InvalidArgument("degree overflow".into()))?;
399            edges.push((src, dst));
400            history.push_back(Some(dst));
401        }
402        // 4. Per-step sentinel.
403        history.push_back(None);
404
405        // 5. Refresh weights for each cited target with NEW degree + age.
406        let start = edges.len() - no_of_neighbors;
407        for edge in &edges[start..] {
408            let nn = edge.1 as usize;
409            let age = ((i - nn) / binwidth + 1) as u32;
410            let w = weight(degree[nn], age, pa_exp, aging_exp, zero_appeal);
411            psum.set(nn, w);
412        }
413
414        // 6. Insert vertex `i` into the BIT.
415        if outpref {
416            // outpref bumps source degree by no_of_neighbors. NOT pushed
417            // to the history deque (so this contribution to degree[i] is
418            // permanent — matches C lines 349-354).
419            let bump = u32::try_from(no_of_neighbors)
420                .map_err(|_| IgraphError::InvalidArgument("neighbors count overflow".into()))?;
421            degree[i] = degree[i]
422                .checked_add(bump)
423                .ok_or_else(|| IgraphError::InvalidArgument("degree overflow".into()))?;
424            // Self has age 0 (`(i - i) / binwidth + 1 = 1`), but the
425            // weight is just `degree_term(degree[i], ...)`. The C source
426            // here writes `pow(degree, pa_exp) + zero_appeal` WITHOUT the
427            // age multiplier (C line 353) — match exactly.
428            psum.set(i, degree_term(degree[i], pa_exp, zero_appeal));
429        } else {
430            // C line 356: bare `zero_appeal`, no age multiplier.
431            psum.set(i, zero_appeal);
432        }
433
434        // 7. Age sweep: every vertex at position `i - k*binwidth`
435        // (k ≥ 1) just crossed a bin boundary. Refresh with age
436        // `k + 2` (matches the C loop at lines 360-368).
437        let mut k = 1usize;
438        loop {
439            let offset = binwidth.saturating_mul(k);
440            if offset > i {
441                break;
442            }
443            let shnode = i - offset;
444            let deg = degree[shnode];
445            let new_age = (k + 2) as u32;
446            let w = weight(deg, new_age, pa_exp, aging_exp, zero_appeal);
447            psum.set(shnode, w);
448            k += 1;
449        }
450    }
451
452    graph.add_edges(edges)?;
453    Ok(graph)
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use std::collections::HashSet;
460
461    fn collect_edges(g: &Graph) -> Vec<(VertexId, VertexId)> {
462        let n = u32::try_from(g.ecount()).expect("ecount fits u32 in tests");
463        (0..n)
464            .map(|eid| g.edge(eid).expect("edge id in bounds"))
465            .collect()
466    }
467
468    fn default_call(
469        nodes: u32,
470        m: u32,
471        outpref: bool,
472        pa_exp: f64,
473        aging_exp: f64,
474        time_window: u32,
475        directed: bool,
476        seed: u64,
477    ) -> IgraphResult<Graph> {
478        recent_degree_aging_game(
479            nodes,
480            m,
481            None,
482            outpref,
483            pa_exp,
484            aging_exp,
485            10,
486            time_window,
487            1.0,
488            directed,
489            seed,
490        )
491    }
492
493    #[test]
494    fn rdaging_n_zero_returns_empty() {
495        let g = default_call(0, 2, false, 1.0, 0.0, 5, true, 1).unwrap();
496        assert_eq!(g.vcount(), 0);
497        assert_eq!(g.ecount(), 0);
498    }
499
500    #[test]
501    fn rdaging_n_one_singleton() {
502        let g = default_call(1, 2, false, 1.0, 0.0, 5, true, 1).unwrap();
503        assert_eq!(g.vcount(), 1);
504        assert_eq!(g.ecount(), 0);
505    }
506
507    #[test]
508    fn rdaging_m_zero_edgeless() {
509        let g = default_call(10, 0, false, 1.0, 0.0, 5, true, 1).unwrap();
510        assert_eq!(g.ecount(), 0);
511    }
512
513    #[test]
514    fn rdaging_ecount_exact_constant_m() {
515        let g = default_call(50, 3, false, 1.0, 0.0, 8, true, 42).unwrap();
516        assert_eq!(g.vcount(), 50);
517        assert_eq!(g.ecount(), 49 * 3);
518    }
519
520    #[test]
521    fn rdaging_ecount_exact_outseq() {
522        let outseq: Vec<u32> = (0..20)
523            .map(|i| if i == 0 { 0 } else { (i % 3) + 1 })
524            .collect();
525        let expected_edges: usize = outseq.iter().skip(1).map(|&x| x as usize).sum();
526        let g =
527            recent_degree_aging_game(20, 0, Some(&outseq), false, 1.0, -0.5, 5, 6, 1.0, true, 7)
528                .unwrap();
529        assert_eq!(g.vcount(), 20);
530        assert_eq!(g.ecount(), expected_edges);
531    }
532
533    #[test]
534    fn rdaging_no_self_loops() {
535        let g = default_call(80, 4, false, 1.0, -1.0, 5, true, 123).unwrap();
536        for (s, d) in collect_edges(&g) {
537            assert_ne!(s, d, "self-loop at edge ({s}, {d})");
538        }
539    }
540
541    #[test]
542    fn rdaging_source_is_step_index() {
543        let m: u32 = 3;
544        let n: u32 = 30;
545        let g = default_call(n, m, false, 1.0, -0.5, 5, true, 99).unwrap();
546        let edges = collect_edges(&g);
547        for (idx, (s, _d)) in edges.iter().enumerate() {
548            let expected_src = (idx as u32 / m) + 1;
549            assert_eq!(
550                *s, expected_src,
551                "edge {idx}: src {s} != expected {expected_src}"
552            );
553        }
554    }
555
556    #[test]
557    fn rdaging_target_below_source() {
558        let m: u32 = 2;
559        let n: u32 = 40;
560        let g = default_call(n, m, false, 1.0, -0.5, 5, true, 7).unwrap();
561        let edges = collect_edges(&g);
562        for (idx, (s, d)) in edges.iter().enumerate() {
563            assert!(*d < *s, "edge {idx}: target {d} should be < source {s}");
564        }
565    }
566
567    #[test]
568    fn rdaging_deterministic_same_seed() {
569        let g1 = default_call(40, 3, false, 1.0, -0.5, 5, true, 11).unwrap();
570        let g2 = default_call(40, 3, false, 1.0, -0.5, 5, true, 11).unwrap();
571        assert_eq!(collect_edges(&g1), collect_edges(&g2));
572    }
573
574    #[test]
575    fn rdaging_seed_divergence() {
576        let g1 = default_call(40, 3, false, 1.0, -0.5, 5, true, 11).unwrap();
577        let g2 = default_call(40, 3, false, 1.0, -0.5, 5, true, 12).unwrap();
578        assert_ne!(collect_edges(&g1), collect_edges(&g2));
579    }
580
581    #[test]
582    fn rdaging_directed_flag_propagates() {
583        let g_dir = default_call(20, 2, false, 1.0, 0.0, 5, true, 1).unwrap();
584        let g_undir = default_call(20, 2, false, 1.0, 0.0, 5, false, 1).unwrap();
585        assert!(g_dir.is_directed());
586        assert!(!g_undir.is_directed());
587    }
588
589    #[test]
590    fn rdaging_outpref_changes_graph() {
591        let g_off = default_call(30, 2, false, 1.0, -1.0, 5, true, 5).unwrap();
592        let g_on = default_call(30, 2, true, 1.0, -1.0, 5, true, 5).unwrap();
593        // outpref toggling changes the BIT weights from step 2 onward,
594        // so the edge stream should differ.
595        assert_ne!(collect_edges(&g_off), collect_edges(&g_on));
596    }
597
598    #[test]
599    fn rdaging_short_window_changes_graph() {
600        // A tiny time_window forces faster decay than a long window
601        // (different vertices end up with non-zero recent_degree), so
602        // the edge stream should differ.
603        let g_short = default_call(50, 2, false, 1.0, -1.0, 2, true, 17).unwrap();
604        let g_long = default_call(50, 2, false, 1.0, -1.0, 40, true, 17).unwrap();
605        assert_ne!(collect_edges(&g_short), collect_edges(&g_long));
606    }
607
608    #[test]
609    fn rdaging_strong_aging_lifts_young_share() {
610        let make = |aging_exp: f64| {
611            recent_degree_aging_game(200, 2, None, false, 1.0, aging_exp, 50, 10, 1.0, true, 33)
612                .unwrap()
613        };
614        let indeg = |g: &Graph| -> Vec<u32> {
615            let mut v = vec![0u32; 200];
616            for (_s, d) in collect_edges(g) {
617                v[d as usize] += 1;
618            }
619            v
620        };
621        let aged = indeg(&make(-5.0));
622        let flat = indeg(&make(0.0));
623        let young_aged: u32 = aged[100..].iter().sum();
624        let young_flat: u32 = flat[100..].iter().sum();
625        assert!(
626            young_aged > young_flat,
627            "young-in under strong aging ({young_aged}) should exceed young-in under no aging ({young_flat})"
628        );
629    }
630
631    #[test]
632    fn rdaging_targets_unique_when_strongly_aged() {
633        let g = default_call(40, 2, false, 1.0, -3.0, 5, true, 21).unwrap();
634        let mut seen: HashSet<(VertexId, VertexId)> = HashSet::new();
635        for e in collect_edges(&g) {
636            assert_ne!(e.0, e.1);
637            seen.insert(e); // duplicates allowed per the model
638        }
639    }
640
641    #[test]
642    fn rdaging_validation_aging_bins_zero() {
643        let err =
644            recent_degree_aging_game(10, 2, None, false, 1.0, 0.0, 0, 5, 1.0, true, 1).unwrap_err();
645        assert!(matches!(err, IgraphError::InvalidArgument(_)));
646    }
647
648    #[test]
649    fn rdaging_validation_negative_zero_appeal() {
650        let err = recent_degree_aging_game(10, 2, None, false, 1.0, 0.0, 5, 5, -1.0, true, 1)
651            .unwrap_err();
652        assert!(matches!(err, IgraphError::InvalidArgument(_)));
653    }
654
655    #[test]
656    fn rdaging_validation_pa_exp_nan() {
657        let err = recent_degree_aging_game(10, 2, None, false, f64::NAN, 0.0, 5, 5, 1.0, true, 1)
658            .unwrap_err();
659        assert!(matches!(err, IgraphError::InvalidArgument(_)));
660    }
661
662    #[test]
663    fn rdaging_validation_aging_exp_nan() {
664        let err = recent_degree_aging_game(10, 2, None, false, 1.0, f64::NAN, 5, 5, 1.0, true, 1)
665            .unwrap_err();
666        assert!(matches!(err, IgraphError::InvalidArgument(_)));
667    }
668
669    #[test]
670    fn rdaging_validation_outseq_wrong_length() {
671        let outseq = vec![0u32; 5];
672        let err =
673            recent_degree_aging_game(10, 0, Some(&outseq), false, 1.0, 0.0, 5, 5, 1.0, true, 1)
674                .unwrap_err();
675        assert!(matches!(err, IgraphError::InvalidArgument(_)));
676    }
677
678    #[test]
679    fn rdaging_time_window_zero_collapses_to_pure_aging() {
680        // With time_window = 0, every recent-degree counter expires
681        // immediately at the start of step `i+1`. The weight is then
682        // dominated by `zero_appeal * pow(age, aging_exp)` — a pure
683        // aging walk with floor.
684        let g =
685            recent_degree_aging_game(30, 2, None, false, 1.0, -1.0, 5, 0, 1.0, true, 11).unwrap();
686        assert_eq!(g.vcount(), 30);
687        assert_eq!(g.ecount(), 29 * 2);
688        for (s, d) in collect_edges(&g) {
689            assert_ne!(s, d);
690            assert!(d < s);
691        }
692    }
693}
694
695#[cfg(all(test, feature = "proptest-harness"))]
696mod proptests {
697    use super::*;
698    use proptest::prelude::*;
699
700    proptest! {
701        #[test]
702        fn rdaging_ecount_exact_constant_m(
703            n in 2u32..40,
704            m in 1u32..6,
705            pa_exp in -1.0_f64..2.0,
706            aging_exp in -2.0_f64..1.0,
707            aging_bins in 1u32..20,
708            time_window in 0u32..20,
709            outpref: bool,
710            directed: bool,
711            seed in 0u64..1_000_000,
712        ) {
713            let g = recent_degree_aging_game(
714                n, m, None, outpref,
715                pa_exp, aging_exp, aging_bins, time_window,
716                1.0, directed, seed,
717            ).unwrap();
718            prop_assert_eq!(g.vcount(), n);
719            prop_assert_eq!(g.ecount() as u32, (n - 1) * m);
720        }
721
722        #[test]
723        fn rdaging_no_self_loops(
724            n in 2u32..40,
725            m in 1u32..6,
726            pa_exp in -1.0_f64..2.0,
727            aging_exp in -2.0_f64..1.0,
728            aging_bins in 1u32..20,
729            time_window in 0u32..20,
730            outpref: bool,
731            directed: bool,
732            seed in 0u64..1_000_000,
733        ) {
734            let g = recent_degree_aging_game(
735                n, m, None, outpref,
736                pa_exp, aging_exp, aging_bins, time_window,
737                1.0, directed, seed,
738            ).unwrap();
739            let m_edges = u32::try_from(g.ecount()).unwrap();
740            for eid in 0..m_edges {
741                let (s, d) = g.edge(eid).unwrap();
742                prop_assert_ne!(s, d);
743            }
744        }
745
746        #[test]
747        fn rdaging_source_is_step_index(
748            n in 2u32..40,
749            m in 1u32..6,
750            seed in 0u64..1_000_000,
751        ) {
752            let g = recent_degree_aging_game(
753                n, m, None, false,
754                1.0, -0.5, 5, 5,
755                1.0, true, seed,
756            ).unwrap();
757            let m_edges = u32::try_from(g.ecount()).unwrap();
758            for eid in 0..m_edges {
759                let (s, _d) = g.edge(eid).unwrap();
760                let expected_src = (eid / m) + 1;
761                prop_assert_eq!(s, expected_src);
762            }
763        }
764
765        #[test]
766        fn rdaging_targets_below_source(
767            n in 2u32..40,
768            m in 1u32..6,
769            aging_exp in -2.0_f64..1.0,
770            seed in 0u64..1_000_000,
771        ) {
772            let g = recent_degree_aging_game(
773                n, m, None, false,
774                1.0, aging_exp, 5, 5,
775                1.0, true, seed,
776            ).unwrap();
777            let m_edges = u32::try_from(g.ecount()).unwrap();
778            for eid in 0..m_edges {
779                let (s, d) = g.edge(eid).unwrap();
780                prop_assert!(d < s);
781            }
782        }
783
784        #[test]
785        fn rdaging_determinism(
786            n in 2u32..30,
787            m in 1u32..5,
788            pa_exp in -1.0_f64..2.0,
789            aging_exp in -2.0_f64..1.0,
790            time_window in 0u32..20,
791            outpref: bool,
792            seed in 0u64..1_000_000,
793        ) {
794            let g1 = recent_degree_aging_game(
795                n, m, None, outpref,
796                pa_exp, aging_exp, 5, time_window,
797                1.0, true, seed,
798            ).unwrap();
799            let g2 = recent_degree_aging_game(
800                n, m, None, outpref,
801                pa_exp, aging_exp, 5, time_window,
802                1.0, true, seed,
803            ).unwrap();
804            let m_edges = u32::try_from(g1.ecount()).unwrap();
805            for eid in 0..m_edges {
806                prop_assert_eq!(g1.edge(eid).unwrap(), g2.edge(eid).unwrap());
807            }
808        }
809    }
810}