Skip to main content

rust_igraph/algorithms/games/
barabasi_aging.rs

1//! Barabási–Albert preferential-attachment with aging (ALGO-GN-021).
2//!
3//! Counterpart of `igraph_barabasi_aging_game()` in
4//! `references/igraph/src/games/barabasi.c` (lines ~606-841).
5//!
6//! Each step `i ≥ 1` adds a fresh vertex and attaches `m`
7//! (or `outseq[i]`) outgoing edges. Targets are drawn from a Fenwick BIT
8//! (`PsumTree`) weighted by a product of a degree term and an age term:
9//!
10//! ```text
11//! weight(v) = (deg_coef · pow(deg(v), pa_exp) + zero_deg_appeal)
12//!           · (age_coef · pow(age(v), aging_exp) + zero_age_appeal)
13//! ```
14//!
15//! `age(v) = (i − v) / binwidth + 1` where `binwidth = nodes / aging_bins + 1`
16//! (the C special case is `pow(0, 0) == 1`, preserved here).
17//!
18//! ## Mechanics
19//!
20//! * **Sampling**: each of the `m` draws independently picks against the
21//!   current BIT (the C source does NOT zero picks within a step, so
22//!   within-step multi-edges are possible — matching upstream).
23//! * **Refresh chosen weights**: after the `m` draws, every chosen vertex
24//!   has its weight refreshed to the new degree.
25//! * **New-vertex weight**: vertex `i` is inserted into the BIT with age
26//!   `1` (the newest bin) and degree either `0` (if `outpref = false`)
27//!   or `no_of_neighbors` (if `outpref = true`).
28//! * **Age sweep**: for every `k ≥ 1` such that `k · binwidth ≤ i`, the
29//!   vertex at position `i − k · binwidth` has its weight refreshed with
30//!   age `k + 2` (it just crossed a bin boundary). This matches the C
31//!   loop at lines 817-829.
32//!
33//! ## Parameters
34//!
35//! * `nodes` — vertex count.
36//! * `m` — per-step out-degree when `outseq` is `None`.
37//! * `outseq` — optional length-`nodes` vector of per-step out-degrees
38//!   (the first entry is ignored, matching upstream).
39//! * `outpref` — when `true`, the new vertex's own emitted citations
40//!   feed back into its degree at end-of-step (and therefore into its
41//!   weight when it becomes a target in subsequent steps).
42//! * `pa_exp` — preferential-attachment exponent on degree.
43//! * `aging_exp` — aging exponent on age bin (usually negative —
44//!   younger vertices are favoured).
45//! * `aging_bins` — must be `≥ 1`. Sets `binwidth = nodes / aging_bins + 1`.
46//! * `zero_deg_appeal` — additive degree term so zero-degree vertices
47//!   have non-zero weight. Must be non-negative.
48//! * `zero_age_appeal` — additive age term so oldest vertices retain
49//!   non-zero weight when `aging_exp < 0`. Must be non-negative.
50//! * `deg_coef` — multiplicative degree coefficient. Must be non-negative.
51//! * `age_coef` — multiplicative age coefficient. Must be non-negative.
52//! * `directed` — emit directed edges (newer → older convention).
53//! * `seed` — initialises an internal `SplitMix64`.
54//!
55//! ## Construction guarantees
56//!
57//! * **No self-loops** by construction — the new vertex `i` is added to
58//!   the BIT *after* its `m` outgoing draws, and `search_bounded(_, i)`
59//!   prevents binary-lifting from over-advancing to position `i`.
60//! * **Multi-edges allowed** when `m ≥ 2` — independent draws against
61//!   the same snapshot of the BIT can collide. Matches upstream's
62//!   behaviour (no within-step zeroing).
63//! * **Zero-sum fallback**: when the BIT total is zero, the draw falls
64//!   back to a uniform sample over `[0, i)`. This only fires at the
65//!   start of the run when the seed vertex's age has already pushed its
66//!   weight to zero (e.g. `aging_exp = -∞` is forbidden by validation,
67//!   but `aging_exp` very negative + `zero_age_appeal = 0` makes it
68//!   possible).
69//!
70//! ## Cost
71//!
72//! `O((n + n / aging_bins) · log n + |E|)` — each step does
73//! `O(m · log n)` for the draws and refresh, plus an amortized
74//! `O((n / aging_bins) · log n)` for the age sweep across the run.
75//!
76//! ## Notes on the C special case
77//!
78//! `attraction_aging` in C uses `dp = (pa_exp == 0.0) ? 1.0 : pow(deg, pa_exp)`
79//! — i.e. `pow(0, 0) == 1` is special-cased only for the degree term
80//! and only when the exponent is exactly zero. The age term always
81//! evaluates `pow(age, aging_exp)` directly (and since `age ≥ 1` after
82//! the `+1` shift, there is no `pow(0, *)` edge case for age).
83
84#![allow(
85    clippy::cast_possible_truncation,
86    clippy::cast_sign_loss,
87    clippy::cast_precision_loss,
88    clippy::too_many_arguments,
89    clippy::too_many_lines,
90    clippy::needless_range_loop
91)]
92
93use crate::core::rng::SplitMix64;
94use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
95
96/// Fenwick-tree-based prefix-sum store with O(log n) point set and
97/// O(log n) prefix-target search bounded to `[0, bound)`.
98///
99/// Duplicated from [`crate::barabasi_game_psumtree`] /
100/// [`crate::recent_degree_game`] so this module is self-contained.
101struct PsumTree {
102    n: usize,
103    bit: Vec<f64>,
104    values: Vec<f64>,
105    total: f64,
106}
107
108impl PsumTree {
109    fn new(n: usize) -> Self {
110        Self {
111            n,
112            bit: vec![0.0; n + 1],
113            values: vec![0.0; n],
114            total: 0.0,
115        }
116    }
117
118    fn set(&mut self, i: usize, v: f64) {
119        let delta = v - self.values[i];
120        self.values[i] = v;
121        self.total += delta;
122        let mut k = i + 1;
123        while k <= self.n {
124            self.bit[k] += delta;
125            k += k & k.wrapping_neg();
126        }
127    }
128
129    fn total(&self) -> f64 {
130        self.total
131    }
132
133    /// Binary-lifted prefix-sum search constrained to `[0, bound)`.
134    fn search_bounded(&self, target: f64, bound: usize) -> usize {
135        if bound == 0 {
136            return 0;
137        }
138        let mut idx: usize = 0;
139        let mut remaining = target;
140        let mut step = 1usize;
141        while step.saturating_mul(2) <= bound {
142            step *= 2;
143        }
144        while step > 0 {
145            let next = idx + step;
146            if next <= bound && self.bit[next] <= remaining {
147                idx = next;
148                remaining -= self.bit[next];
149            }
150            step >>= 1;
151        }
152        idx.min(bound - 1)
153    }
154}
155
156/// Mirrors `attraction_aging()` at `barabasi.c:606`.
157///
158/// `weight = (deg_coef · dp + zero_deg_appeal) · (age_coef · ap + zero_age_appeal)`
159/// where `dp = pow(deg, pa_exp)` (forced to `1.0` when `pa_exp == 0`)
160/// and `ap = pow(age, aging_exp)`.
161fn attraction_aging(
162    degree: u32,
163    age: u32,
164    pa_exp: f64,
165    aging_exp: f64,
166    zero_deg_appeal: f64,
167    zero_age_appeal: f64,
168    deg_coef: f64,
169    age_coef: f64,
170) -> f64 {
171    let dp = if pa_exp == 0.0 {
172        1.0
173    } else if degree == 0 {
174        if pa_exp > 0.0 { 0.0 } else { f64::INFINITY }
175    } else {
176        f64::from(degree).powf(pa_exp)
177    };
178    let ap = if age == 0 {
179        // `age >= 1` is always true in our caller (we feed `age + 1`,
180        // `age + 2`, or `1` for the new vertex). Guard anyway.
181        if aging_exp == 0.0 {
182            1.0
183        } else if aging_exp > 0.0 {
184            0.0
185        } else {
186            f64::INFINITY
187        }
188    } else {
189        f64::from(age).powf(aging_exp)
190    };
191    (deg_coef * dp + zero_deg_appeal) * (age_coef * ap + zero_age_appeal)
192}
193
194fn validate(
195    nodes: u32,
196    m: u32,
197    outseq: Option<&[u32]>,
198    pa_exp: f64,
199    aging_exp: f64,
200    aging_bins: u32,
201    zero_deg_appeal: f64,
202    zero_age_appeal: f64,
203    deg_coef: f64,
204    age_coef: f64,
205) -> IgraphResult<()> {
206    if !pa_exp.is_finite() {
207        return Err(IgraphError::InvalidArgument(format!(
208            "pa_exp must be finite (got {pa_exp})"
209        )));
210    }
211    if !aging_exp.is_finite() {
212        return Err(IgraphError::InvalidArgument(format!(
213            "aging_exp must be finite (got {aging_exp})"
214        )));
215    }
216    if aging_bins == 0 {
217        return Err(IgraphError::InvalidArgument(
218            "aging_bins must be positive (got 0)".into(),
219        ));
220    }
221    if !deg_coef.is_finite() || deg_coef < 0.0 {
222        return Err(IgraphError::InvalidArgument(format!(
223            "deg_coef must be finite and non-negative (got {deg_coef})"
224        )));
225    }
226    if !age_coef.is_finite() || age_coef < 0.0 {
227        return Err(IgraphError::InvalidArgument(format!(
228            "age_coef must be finite and non-negative (got {age_coef})"
229        )));
230    }
231    if !zero_deg_appeal.is_finite() || zero_deg_appeal < 0.0 {
232        return Err(IgraphError::InvalidArgument(format!(
233            "zero_deg_appeal must be finite and non-negative (got {zero_deg_appeal})"
234        )));
235    }
236    if !zero_age_appeal.is_finite() || zero_age_appeal < 0.0 {
237        return Err(IgraphError::InvalidArgument(format!(
238            "zero_age_appeal must be finite and non-negative (got {zero_age_appeal})"
239        )));
240    }
241    if let Some(seq) = outseq {
242        if seq.len() != nodes as usize {
243            return Err(IgraphError::InvalidArgument(format!(
244                "outseq length ({}) must equal nodes ({nodes})",
245                seq.len()
246            )));
247        }
248    }
249    let _ = m;
250    Ok(())
251}
252
253fn edge_capacity(nodes: u32, m: u32, outseq: Option<&[u32]>) -> usize {
254    let n = nodes as usize;
255    match outseq {
256        Some(seq) => seq.iter().skip(1).map(|&x| x as usize).sum(),
257        None => n.saturating_sub(1).saturating_mul(m as usize),
258    }
259}
260
261/// Preferential attachment with vertex aging.
262///
263/// See [module docs](self) for the full contract.
264///
265/// # Errors
266///
267/// * `pa_exp` / `aging_exp` not finite.
268/// * `aging_bins == 0`.
269/// * `deg_coef` / `age_coef` / `zero_deg_appeal` / `zero_age_appeal`
270///   negative or non-finite.
271/// * `outseq.len() != nodes`.
272///
273/// # Examples
274///
275/// ```
276/// use rust_igraph::barabasi_aging_game;
277///
278/// // 100-vertex directed graph with classical preferential attachment
279/// // (pa_exp = 1) and no aging (aging_exp = 0, age_coef = 0, so the
280/// // age term collapses to a constant `zero_age_appeal = 1.0`). Total
281/// // edges: (n - 1) * m = 99 * 2 = 198.
282/// let g = barabasi_aging_game(
283///     100, 2, None, false,
284///     1.0, 0.0, 10,
285///     1.0, 1.0,
286///     1.0, 0.0,
287///     true, 0x42,
288/// ).unwrap();
289/// assert_eq!(g.vcount(), 100);
290/// assert_eq!(g.ecount(), 198);
291/// ```
292pub fn barabasi_aging_game(
293    nodes: u32,
294    m: u32,
295    outseq: Option<&[u32]>,
296    outpref: bool,
297    pa_exp: f64,
298    aging_exp: f64,
299    aging_bins: u32,
300    zero_deg_appeal: f64,
301    zero_age_appeal: f64,
302    deg_coef: f64,
303    age_coef: f64,
304    directed: bool,
305    seed: u64,
306) -> IgraphResult<Graph> {
307    validate(
308        nodes,
309        m,
310        outseq,
311        pa_exp,
312        aging_exp,
313        aging_bins,
314        zero_deg_appeal,
315        zero_age_appeal,
316        deg_coef,
317        age_coef,
318    )?;
319
320    let mut graph = Graph::new(nodes, directed)?;
321    if nodes < 2 {
322        return Ok(graph);
323    }
324    if outseq.is_none() && m == 0 {
325        return Ok(graph);
326    }
327    if let Some(seq) = outseq {
328        let after_zero: u64 = seq.iter().skip(1).map(|&x| u64::from(x)).sum();
329        if after_zero == 0 {
330            return Ok(graph);
331        }
332    }
333
334    let n = nodes as usize;
335    let binwidth = (n / aging_bins as usize) + 1;
336    let mut rng = SplitMix64::new(seed);
337    let mut psum = PsumTree::new(n);
338    let mut degree: Vec<u32> = vec![0; n];
339    let capacity = edge_capacity(nodes, m, outseq);
340    let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(capacity);
341
342    // Seed vertex: weight 1.0 (matches upstream's "any positive value"
343    // initial placeholder; refreshed properly on step 1).
344    psum.set(0, 1.0);
345
346    let mut picks: Vec<usize> = Vec::with_capacity(m as usize);
347
348    for i in 1..n {
349        let no_of_neighbors = if let Some(seq) = outseq {
350            seq[i] as usize
351        } else {
352            m as usize
353        };
354
355        picks.clear();
356
357        // The C source samples against the live BIT for each of the m
358        // draws WITHOUT zeroing picks within the step. Independent
359        // draws against the (slowly evolving) tree may therefore
360        // collide, producing within-step multi-edges. We mirror that.
361        let sum_snapshot = psum.total();
362        for _ in 0..no_of_neighbors {
363            let to = if sum_snapshot > 0.0 {
364                let target = rng.gen_unit() * sum_snapshot;
365                psum.search_bounded(target, i)
366            } else {
367                let span = i as u64;
368                (rng.next_u64() % span) as usize
369            };
370            degree[to] += 1;
371            edges.push((
372                u32::try_from(i)
373                    .map_err(|_| IgraphError::Internal("vertex index overflows u32"))?,
374                u32::try_from(to)
375                    .map_err(|_| IgraphError::Internal("vertex index overflows u32"))?,
376            ));
377            picks.push(to);
378        }
379
380        // Refresh weights of every chosen vertex with their new degree
381        // (age is `(i - n) / binwidth + 1` — matches the C loop at
382        // lines 786-796 which uses `age + 1`).
383        for &nn in &picks {
384            let age = (i - nn) / binwidth + 1;
385            let w = attraction_aging(
386                degree[nn],
387                age as u32,
388                pa_exp,
389                aging_exp,
390                zero_deg_appeal,
391                zero_age_appeal,
392                deg_coef,
393                age_coef,
394            );
395            psum.set(nn, w);
396        }
397
398        // Insert vertex `i` into the BIT at age 1 (newest bin).
399        if outpref {
400            degree[i] += no_of_neighbors as u32;
401            let w = attraction_aging(
402                degree[i],
403                1,
404                pa_exp,
405                aging_exp,
406                zero_deg_appeal,
407                zero_age_appeal,
408                deg_coef,
409                age_coef,
410            );
411            psum.set(i, w);
412        } else {
413            let w = attraction_aging(
414                0,
415                1,
416                pa_exp,
417                aging_exp,
418                zero_deg_appeal,
419                zero_age_appeal,
420                deg_coef,
421                age_coef,
422            );
423            psum.set(i, w);
424        }
425
426        // Age sweep: every vertex at position `i - k*binwidth`
427        // (k ≥ 1) just crossed a bin boundary. Refresh with age
428        // `k + 2` (matches the C loop at lines 817-829).
429        let mut k = 1usize;
430        loop {
431            let offset = binwidth.saturating_mul(k);
432            if offset > i {
433                break;
434            }
435            let shnode = i - offset;
436            let deg = degree[shnode];
437            let new_age = (k + 2) as u32;
438            let w = attraction_aging(
439                deg,
440                new_age,
441                pa_exp,
442                aging_exp,
443                zero_deg_appeal,
444                zero_age_appeal,
445                deg_coef,
446                age_coef,
447            );
448            psum.set(shnode, w);
449            k += 1;
450        }
451    }
452
453    graph.add_edges(edges)?;
454    Ok(graph)
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use std::collections::HashSet;
461
462    fn collect_edges(g: &Graph) -> Vec<(VertexId, VertexId)> {
463        let n = u32::try_from(g.ecount()).expect("ecount fits u32 in tests");
464        (0..n)
465            .map(|eid| g.edge(eid).expect("edge id in bounds"))
466            .collect()
467    }
468
469    fn default_call(
470        nodes: u32,
471        m: u32,
472        outpref: bool,
473        pa_exp: f64,
474        aging_exp: f64,
475        directed: bool,
476        seed: u64,
477    ) -> IgraphResult<Graph> {
478        barabasi_aging_game(
479            nodes, m, None, outpref, pa_exp, aging_exp, 10, 1.0, 1.0, 1.0, 1.0, directed, seed,
480        )
481    }
482
483    #[test]
484    fn aging_n_zero_returns_empty() {
485        let g = default_call(0, 2, false, 1.0, 0.0, true, 1).unwrap();
486        assert_eq!(g.vcount(), 0);
487        assert_eq!(g.ecount(), 0);
488    }
489
490    #[test]
491    fn aging_n_one_singleton() {
492        let g = default_call(1, 2, false, 1.0, 0.0, true, 1).unwrap();
493        assert_eq!(g.vcount(), 1);
494        assert_eq!(g.ecount(), 0);
495    }
496
497    #[test]
498    fn aging_m_zero_edgeless() {
499        let g = default_call(10, 0, false, 1.0, 0.0, true, 1).unwrap();
500        assert_eq!(g.ecount(), 0);
501    }
502
503    #[test]
504    fn aging_ecount_exact_constant_m() {
505        let g = default_call(50, 3, false, 1.0, 0.0, true, 42).unwrap();
506        assert_eq!(g.vcount(), 50);
507        assert_eq!(g.ecount(), 49 * 3);
508    }
509
510    #[test]
511    fn aging_ecount_exact_outseq() {
512        let outseq: Vec<u32> = (0..20)
513            .map(|i| if i == 0 { 0 } else { (i % 3) + 1 })
514            .collect();
515        let expected_edges: usize = outseq.iter().skip(1).map(|&x| x as usize).sum();
516        let g = barabasi_aging_game(
517            20,
518            0,
519            Some(&outseq),
520            false,
521            1.0,
522            -0.5,
523            5,
524            1.0,
525            1.0,
526            1.0,
527            1.0,
528            true,
529            7,
530        )
531        .unwrap();
532        assert_eq!(g.vcount(), 20);
533        assert_eq!(g.ecount(), expected_edges);
534    }
535
536    #[test]
537    fn aging_no_self_loops() {
538        let g = default_call(80, 4, false, 1.0, -1.0, true, 123).unwrap();
539        for (s, d) in collect_edges(&g) {
540            assert_ne!(s, d, "self-loop at edge ({s}, {d})");
541        }
542    }
543
544    #[test]
545    fn aging_source_is_step_index() {
546        // Edges are emitted in step order; for constant-m the first m
547        // edges come from vertex 1, next m from vertex 2, etc.
548        let m: u32 = 3;
549        let n: u32 = 30;
550        let g = default_call(n, m, false, 1.0, -0.5, true, 99).unwrap();
551        let edges = collect_edges(&g);
552        for (idx, (s, _d)) in edges.iter().enumerate() {
553            let expected_src = (idx as u32 / m) + 1;
554            assert_eq!(
555                *s, expected_src,
556                "edge {idx}: src {s} != expected {expected_src}"
557            );
558        }
559    }
560
561    #[test]
562    fn aging_target_in_zero_to_step_minus_one() {
563        let m: u32 = 2;
564        let n: u32 = 40;
565        let g = default_call(n, m, false, 1.0, -0.5, true, 7).unwrap();
566        let edges = collect_edges(&g);
567        for (idx, (s, d)) in edges.iter().enumerate() {
568            assert!(*d < *s, "edge {idx}: target {d} should be < source {s}");
569        }
570    }
571
572    #[test]
573    fn aging_deterministic_same_seed() {
574        let g1 = default_call(40, 3, false, 1.0, -0.5, true, 11).unwrap();
575        let g2 = default_call(40, 3, false, 1.0, -0.5, true, 11).unwrap();
576        assert_eq!(collect_edges(&g1), collect_edges(&g2));
577    }
578
579    #[test]
580    fn aging_seed_divergence() {
581        let g1 = default_call(40, 3, false, 1.0, -0.5, true, 11).unwrap();
582        let g2 = default_call(40, 3, false, 1.0, -0.5, true, 12).unwrap();
583        assert_ne!(collect_edges(&g1), collect_edges(&g2));
584    }
585
586    #[test]
587    fn aging_directed_flag_propagates() {
588        let g_dir = default_call(20, 2, false, 1.0, 0.0, true, 1).unwrap();
589        let g_undir = default_call(20, 2, false, 1.0, 0.0, false, 1).unwrap();
590        assert!(g_dir.is_directed());
591        assert!(!g_undir.is_directed());
592    }
593
594    #[test]
595    fn aging_outpref_changes_graph() {
596        let g_off = default_call(30, 2, false, 1.0, -1.0, true, 5).unwrap();
597        let g_on = default_call(30, 2, true, 1.0, -1.0, true, 5).unwrap();
598        // outpref toggling changes the BIT weights from step 2 onward,
599        // so the edge stream should differ.
600        assert_ne!(collect_edges(&g_off), collect_edges(&g_on));
601    }
602
603    #[test]
604    fn aging_strong_aging_lifts_young_share_vs_no_aging() {
605        // Directional sanity: turning on strong negative aging should
606        // shift in-degree toward the younger half of vertices,
607        // compared to the no-aging baseline (aging_exp = 0).
608        let make = |aging_exp: f64| {
609            barabasi_aging_game(
610                200, 2, None, false, 1.0, aging_exp, 50, 1.0, 0.0, 1.0, 1.0, true, 33,
611            )
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 aging_classical_matches_classical_ba_shape() {
633        // With pa_exp = 1, aging_exp = 0, age_coef = 0,
634        // zero_age_appeal = 1, the age term collapses to a constant
635        // and the model reduces to classical preferential attachment.
636        // Edge count should be exact and the in-degree of the seed
637        // vertex should still be the largest hub.
638        let g = barabasi_aging_game(
639            100, 2, None, false, 1.0, 0.0, 10, 1.0, 1.0, 1.0, 0.0, true, 17,
640        )
641        .unwrap();
642        assert_eq!(g.ecount(), 99 * 2);
643        let mut indeg = vec![0u32; 100];
644        for (_s, d) in collect_edges(&g) {
645            indeg[d as usize] += 1;
646        }
647        let max_idx = (0..100).max_by_key(|&i| indeg[i]).expect("100 candidates");
648        // Seed vertex (0) or one of the very early vertices should win.
649        assert!(
650            max_idx < 10,
651            "max-indegree vertex should be among the oldest 10 (got {max_idx})"
652        );
653    }
654
655    #[test]
656    fn aging_validation_aging_bins_zero() {
657        let err = barabasi_aging_game(10, 2, None, false, 1.0, 0.0, 0, 1.0, 1.0, 1.0, 1.0, true, 1)
658            .unwrap_err();
659        assert!(matches!(err, IgraphError::InvalidArgument(_)));
660    }
661
662    #[test]
663    fn aging_validation_negative_deg_coef() {
664        let err = barabasi_aging_game(
665            10, 2, None, false, 1.0, 0.0, 5, 1.0, 1.0, -1.0, 1.0, true, 1,
666        )
667        .unwrap_err();
668        assert!(matches!(err, IgraphError::InvalidArgument(_)));
669    }
670
671    #[test]
672    fn aging_validation_negative_age_coef() {
673        let err = barabasi_aging_game(
674            10, 2, None, false, 1.0, 0.0, 5, 1.0, 1.0, 1.0, -1.0, true, 1,
675        )
676        .unwrap_err();
677        assert!(matches!(err, IgraphError::InvalidArgument(_)));
678    }
679
680    #[test]
681    fn aging_validation_negative_zero_deg_appeal() {
682        let err = barabasi_aging_game(
683            10, 2, None, false, 1.0, 0.0, 5, -1.0, 1.0, 1.0, 1.0, true, 1,
684        )
685        .unwrap_err();
686        assert!(matches!(err, IgraphError::InvalidArgument(_)));
687    }
688
689    #[test]
690    fn aging_validation_negative_zero_age_appeal() {
691        let err = barabasi_aging_game(
692            10, 2, None, false, 1.0, 0.0, 5, 1.0, -1.0, 1.0, 1.0, true, 1,
693        )
694        .unwrap_err();
695        assert!(matches!(err, IgraphError::InvalidArgument(_)));
696    }
697
698    #[test]
699    fn aging_validation_pa_exp_nan() {
700        let err = barabasi_aging_game(
701            10,
702            2,
703            None,
704            false,
705            f64::NAN,
706            0.0,
707            5,
708            1.0,
709            1.0,
710            1.0,
711            1.0,
712            true,
713            1,
714        )
715        .unwrap_err();
716        assert!(matches!(err, IgraphError::InvalidArgument(_)));
717    }
718
719    #[test]
720    fn aging_validation_outseq_wrong_length() {
721        let outseq = vec![0u32; 5];
722        let err = barabasi_aging_game(
723            10,
724            0,
725            Some(&outseq),
726            false,
727            1.0,
728            0.0,
729            5,
730            1.0,
731            1.0,
732            1.0,
733            1.0,
734            true,
735            1,
736        )
737        .unwrap_err();
738        assert!(matches!(err, IgraphError::InvalidArgument(_)));
739    }
740
741    #[test]
742    fn attraction_aging_pa_exp_zero_returns_deg_constant_one() {
743        // dp = 1 when pa_exp = 0 (regardless of degree).
744        let w0 = attraction_aging(0, 1, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
745        let w_big = attraction_aging(1000, 1, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0);
746        // (1*1 + 0) * (1*1 + 1) = 2
747        assert!((w0 - 2.0).abs() < 1e-12);
748        assert!((w_big - 2.0).abs() < 1e-12);
749    }
750
751    #[test]
752    fn aging_targets_are_unique_when_strongly_aged() {
753        // With m = 2 and strong aging suppressing repeats, the chance
754        // of within-step duplicates is non-zero but small. Just check
755        // structural invariant: no edge has identical src and dst.
756        let g = default_call(40, 2, false, 1.0, -3.0, true, 21).unwrap();
757        let mut seen: HashSet<(VertexId, VertexId)> = HashSet::new();
758        for e in collect_edges(&g) {
759            assert_ne!(e.0, e.1);
760            seen.insert(e); // duplicates are allowed per the model
761        }
762    }
763}
764
765#[cfg(all(test, feature = "proptest-harness"))]
766mod proptests {
767    use super::*;
768    use proptest::prelude::*;
769
770    proptest! {
771        #[test]
772        fn aging_ecount_exact_constant_m(
773            n in 2u32..40,
774            m in 1u32..6,
775            pa_exp in -1.0_f64..2.0,
776            aging_exp in -2.0_f64..1.0,
777            aging_bins in 1u32..20,
778            outpref: bool,
779            directed: bool,
780            seed in 0u64..1_000_000,
781        ) {
782            let g = barabasi_aging_game(
783                n, m, None, outpref,
784                pa_exp, aging_exp, aging_bins,
785                1.0, 1.0, 1.0, 1.0,
786                directed, seed,
787            ).unwrap();
788            prop_assert_eq!(g.vcount(), n);
789            prop_assert_eq!(g.ecount() as u32, (n - 1) * m);
790        }
791
792        #[test]
793        fn aging_no_self_loops(
794            n in 2u32..40,
795            m in 1u32..6,
796            pa_exp in -1.0_f64..2.0,
797            aging_exp in -2.0_f64..1.0,
798            aging_bins in 1u32..20,
799            outpref: bool,
800            directed: bool,
801            seed in 0u64..1_000_000,
802        ) {
803            let g = barabasi_aging_game(
804                n, m, None, outpref,
805                pa_exp, aging_exp, aging_bins,
806                1.0, 1.0, 1.0, 1.0,
807                directed, seed,
808            ).unwrap();
809            let m_edges = u32::try_from(g.ecount()).unwrap();
810            for eid in 0..m_edges {
811                let (s, d) = g.edge(eid).unwrap();
812                prop_assert_ne!(s, d);
813            }
814        }
815
816        #[test]
817        fn aging_source_is_step_index(
818            n in 2u32..40,
819            m in 1u32..6,
820            seed in 0u64..1_000_000,
821        ) {
822            let g = barabasi_aging_game(
823                n, m, None, false,
824                1.0, -0.5, 5,
825                1.0, 1.0, 1.0, 1.0,
826                true, seed,
827            ).unwrap();
828            let m_edges = u32::try_from(g.ecount()).unwrap();
829            for eid in 0..m_edges {
830                let (s, _d) = g.edge(eid).unwrap();
831                let expected_src = (eid / m) + 1;
832                prop_assert_eq!(s, expected_src);
833            }
834        }
835
836        #[test]
837        fn aging_targets_below_source(
838            n in 2u32..40,
839            m in 1u32..6,
840            aging_exp in -2.0_f64..1.0,
841            seed in 0u64..1_000_000,
842        ) {
843            let g = barabasi_aging_game(
844                n, m, None, false,
845                1.0, aging_exp, 5,
846                1.0, 1.0, 1.0, 1.0,
847                true, seed,
848            ).unwrap();
849            let m_edges = u32::try_from(g.ecount()).unwrap();
850            for eid in 0..m_edges {
851                let (s, d) = g.edge(eid).unwrap();
852                prop_assert!(d < s);
853            }
854        }
855
856        #[test]
857        fn aging_determinism(
858            n in 2u32..30,
859            m in 1u32..5,
860            pa_exp in -1.0_f64..2.0,
861            aging_exp in -2.0_f64..1.0,
862            outpref: bool,
863            seed in 0u64..1_000_000,
864        ) {
865            let g1 = barabasi_aging_game(
866                n, m, None, outpref,
867                pa_exp, aging_exp, 5,
868                1.0, 1.0, 1.0, 1.0,
869                true, seed,
870            ).unwrap();
871            let g2 = barabasi_aging_game(
872                n, m, None, outpref,
873                pa_exp, aging_exp, 5,
874                1.0, 1.0, 1.0, 1.0,
875                true, seed,
876            ).unwrap();
877            let m_edges = u32::try_from(g1.ecount()).unwrap();
878            for eid in 0..m_edges {
879                prop_assert_eq!(g1.edge(eid).unwrap(), g2.edge(eid).unwrap());
880            }
881        }
882    }
883}