Skip to main content

rust_igraph/algorithms/games/
barabasi_psumtree.rs

1//! Barabási–Albert preferential-attachment random graph — PSUMTREE
2//! variants (ALGO-GN-020).
3//!
4//! Counterparts of the `IGRAPH_BARABASI_PSUMTREE` and
5//! `IGRAPH_BARABASI_PSUMTREE_MULTIPLE` branches of
6//! `igraph_barabasi_game()` in `references/igraph/src/games/barabasi.c`
7//! (`igraph_i_barabasi_game_psumtree_multiple` at lines ~166-294 and
8//! `igraph_i_barabasi_game_psumtree` at lines ~296-414).
9//!
10//! Both variants attach `m` (or `outseq[i]`) outgoing edges from every
11//! new vertex `i`, choosing destinations weighted by
12//! `attraction(deg(v)) = pow(deg(v), power) + A` (with the C-style
13//! special case `pow(0, 0) == 1`). Sampling uses a Fenwick BIT
14//! (`PsumTree`) shared with [`crate::lastcit_game`] /
15//! [`crate::recent_degree_game`] for O(log n) point-set + binary-lifted
16//! prefix-search.
17//!
18//! ## Variants
19//!
20//! * [`barabasi_game_psumtree`] — **simple** variant. Each of the `m`
21//!   draws temporarily zeros the chosen vertex's weight in the BIT so
22//!   that the same target cannot be picked twice within the same step.
23//!   After the `m` draws complete, the weights of the chosen vertices
24//!   are refreshed back to their proper `attraction(deg(v))`. **No
25//!   within-step multi-edges** by construction. (Cross-step duplicates
26//!   are impossible too because the source vertex is fresh each step,
27//!   and the simple-graph guarantee is the contract.)
28//!
29//! * [`barabasi_game_psumtree_multiple`] — **multi-edge** variant. The
30//!   BIT prefix-sum is snapshotted *once* at the start of each step,
31//!   and all `m` draws sample against the unchanged tree. The same
32//!   target may therefore be drawn twice within one step, producing
33//!   multi-edges. After all draws, the affected vertices' weights are
34//!   refreshed in batch.
35//!
36//! ## Common parameters
37//!
38//! * `nodes` — vertex count. `nodes = 0` returns the empty graph.
39//! * `power` — exponent in `pow(deg, power)`. Negative values are
40//!   permitted only when there are no zero-degree vertices being
41//!   sampled (the upstream contract is that `A > 0` rules that
42//!   out — we forward the same rule).
43//! * `m` — constant per-step out-degree, used when `outseq = None`.
44//! * `outseq` — when provided, must have length `nodes`. The first
45//!   entry is ignored (vertex 0 has no outgoing edges per the seed
46//!   convention). For `i ≥ 1`, `outseq[i]` overrides `m`.
47//! * `outpref` — when `true` the new vertex's own out-degree counts
48//!   toward its attraction (total-degree model). Forced to `true`
49//!   when `directed = false`.
50//! * `a` — constant attractiveness term added to every weight.
51//!   When `outpref = false`, must be **strictly positive** so that
52//!   zero-degree vertices have non-zero probability. When
53//!   `outpref = true`, may be zero but must be non-negative.
54//! * `directed` — emit directed edges (older → newer convention
55//!   matches BAG variant).
56//! * `seed` — initialises an internal `SplitMix64`; the output is
57//!   deterministic for fixed parameters and seed.
58//!
59//! ## Construction guarantees
60//!
61//! * **No self-loops** by construction — the new vertex `i` is added
62//!   to the BIT *after* its `m` outgoing draws complete.
63//! * **No cross-step multi-edges**: source `i` is unique each step,
64//!   so all duplicates can only land *within* a single step. The
65//!   simple variant rules those out; the multiple variant allows them.
66//! * **Always-cite fallback**: when `m ≥ i`, all `i` previously-added
67//!   vertices are cited (deterministically), matching upstream's
68//!   `no_of_neighbors >= i` branch (multiple variant only — the simple
69//!   variant cannot satisfy `m ≥ i` without running out of distinct
70//!   targets, which the BIT-zeroing scheme also handles implicitly).
71//! * **Zero-sum fallback**: when the BIT total is zero, the sample
72//!   falls back to a uniform draw over `[0, i)`. With `outpref = true`
73//!   and `A > 0`, this branch only fires in the very first step (where
74//!   the seed vertex's weight is set to `1.0`, so the sum is positive).
75//!
76//! ## Cost
77//!
78//! `O(n · m · log n)` for the BIT searches/updates; `O(n + m·(n-1))`
79//! memory for the edge list. Compares favourably to the BAG variant's
80//! `O(n · m)` for small `m`, and is asymptotically better than O(n²)
81//! naive cumulative-sum sampling.
82
83#![allow(
84    clippy::cast_possible_truncation,
85    clippy::cast_sign_loss,
86    clippy::too_many_arguments,
87    clippy::needless_range_loop
88)]
89
90use crate::core::rng::SplitMix64;
91use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
92
93/// Fenwick-tree-based prefix-sum store with O(log n) point set and
94/// O(log n) prefix-target search.
95///
96/// Shared with [`crate::lastcit_game`] / [`crate::recent_degree_game`];
97/// duplicated here so each module is self-contained.
98struct PsumTree {
99    n: usize,
100    bit: Vec<f64>,
101    values: Vec<f64>,
102    total: f64,
103}
104
105impl PsumTree {
106    fn new(n: usize) -> Self {
107        Self {
108            n,
109            bit: vec![0.0; n + 1],
110            values: vec![0.0; n],
111            total: 0.0,
112        }
113    }
114
115    fn set(&mut self, i: usize, v: f64) {
116        let delta = v - self.values[i];
117        self.values[i] = v;
118        self.total += delta;
119        let mut k = i + 1;
120        while k <= self.n {
121            self.bit[k] += delta;
122            k += k & k.wrapping_neg();
123        }
124    }
125
126    fn total(&self) -> f64 {
127        self.total
128    }
129
130    /// Binary-lifted prefix-sum search constrained to `[0, bound)`.
131    ///
132    /// Returns an index strictly less than `bound`, even if FP drift in
133    /// the BIT entries causes the binary lifting to over-advance.
134    /// Critical for the BA inner loop: the new vertex `i` has weight 0
135    /// in the BIT at search time, but its position is `i` — without
136    /// bounding the search to `bound = i`, drift can cause the search
137    /// to return `i`, producing a self-loop.
138    fn search_bounded(&self, target: f64, bound: usize) -> usize {
139        if bound == 0 {
140            return 0;
141        }
142        let mut idx: usize = 0;
143        let mut remaining = target;
144        let mut step = 1usize;
145        while step.saturating_mul(2) <= bound {
146            step *= 2;
147        }
148        while step > 0 {
149            let next = idx + step;
150            if next <= bound && self.bit[next] <= remaining {
151                idx = next;
152                remaining -= self.bit[next];
153            }
154            step >>= 1;
155        }
156        idx.min(bound - 1)
157    }
158}
159
160/// `attraction(deg, power, A) = pow(deg, power) + A`, with the C
161/// special case `pow(0, 0) == 1`.
162fn attraction(degree: u32, power: f64, a: f64) -> f64 {
163    let term = if power == 0.0 {
164        1.0
165    } else if degree == 0 {
166        // pow(0, p) for p > 0 is 0; for p < 0 it is ±∞. Caller is
167        // expected to set A > 0 in that regime so that the resulting
168        // weight is still finite. The runtime contract is enforced by
169        // `validate`.
170        if power > 0.0 { 0.0 } else { f64::INFINITY }
171    } else {
172        f64::from(degree).powf(power)
173    };
174    term + a
175}
176
177fn validate(
178    nodes: u32,
179    power: f64,
180    m: u32,
181    outseq: Option<&[u32]>,
182    outpref: bool,
183    a: f64,
184) -> IgraphResult<()> {
185    if !power.is_finite() {
186        return Err(IgraphError::InvalidArgument(format!(
187            "power must be finite (got {power})"
188        )));
189    }
190    if !a.is_finite() {
191        return Err(IgraphError::InvalidArgument(format!(
192            "A must be finite (got {a})"
193        )));
194    }
195    if outpref {
196        if a < 0.0 {
197            return Err(IgraphError::InvalidArgument(format!(
198                "A must be non-negative when outpref is true (got {a})"
199            )));
200        }
201    } else if a <= 0.0 {
202        return Err(IgraphError::InvalidArgument(format!(
203            "A must be positive when outpref is false (got {a})"
204        )));
205    }
206    if let Some(seq) = outseq {
207        if seq.len() != nodes as usize {
208            return Err(IgraphError::InvalidArgument(format!(
209                "outseq length ({}) must equal nodes ({nodes})",
210                seq.len()
211            )));
212        }
213    }
214    let _ = m;
215    Ok(())
216}
217
218fn edge_capacity(nodes: u32, m: u32, outseq: Option<&[u32]>) -> usize {
219    let n = nodes as usize;
220    match outseq {
221        Some(seq) => seq.iter().skip(1).map(|&x| x as usize).sum(),
222        None => n.saturating_sub(1).saturating_mul(m as usize),
223    }
224}
225
226/// PSUMTREE (simple) variant — no within-step multi-edges.
227///
228/// See [module docs](self) for the full contract.
229///
230/// # Errors
231///
232/// * `power` or `a` is not finite (NaN / ±∞).
233/// * `outpref = false` and `a <= 0` (would leave zero-degree vertices
234///   un-sampleable).
235/// * `outpref = true` and `a < 0`.
236/// * `outseq.len() != nodes`.
237///
238/// # Examples
239///
240/// ```
241/// use rust_igraph::barabasi_game_psumtree;
242///
243/// // 50-vertex directed BA graph with classical (power=1, A=1) kernel
244/// // and m = 2 outgoing edges per new vertex. Total edge count is
245/// // (n - 1) · m = 49 · 2 = 98. The simple variant emits NO
246/// // within-step duplicates, so the graph is simple.
247/// let g = barabasi_game_psumtree(50, 1.0, 2, None, false, 1.0, true, 0x42).unwrap();
248/// assert_eq!(g.vcount(), 50);
249/// assert_eq!(g.ecount(), 98);
250/// ```
251pub fn barabasi_game_psumtree(
252    nodes: u32,
253    power: f64,
254    m: u32,
255    outseq: Option<&[u32]>,
256    outpref: bool,
257    a: f64,
258    directed: bool,
259    seed: u64,
260) -> IgraphResult<Graph> {
261    let outpref = outpref || !directed;
262    validate(nodes, power, m, outseq, outpref, a)?;
263    barabasi_psumtree_inner(nodes, power, m, outseq, outpref, a, directed, seed, false)
264}
265
266/// `PSUMTREE_MULTIPLE` variant — within-step multi-edges allowed.
267///
268/// See [module docs](self) for the full contract.
269///
270/// # Errors
271///
272/// Same as [`barabasi_game_psumtree`].
273///
274/// # Examples
275///
276/// ```
277/// use rust_igraph::barabasi_game_psumtree_multiple;
278///
279/// // 50-vertex directed BA graph with classical (power=1, A=1) kernel
280/// // and m = 3. The early-cite saturation branch fires for steps where
281/// // `m >= i` (steps 1..=3 here, emitting 1+2+3 = 6 instead of
282/// // 3+3+3 = 9), so the total edge count is
283/// // `(n-1)*m - m*(m-1)/2 = 49*3 - 3 = 144`. The multiple variant
284/// // MAY produce within-step duplicates.
285/// let g = barabasi_game_psumtree_multiple(50, 1.0, 3, None, false, 1.0, true, 0x42).unwrap();
286/// assert_eq!(g.vcount(), 50);
287/// assert_eq!(g.ecount(), 49 * 3 - 3 * 2 / 2);
288/// ```
289pub fn barabasi_game_psumtree_multiple(
290    nodes: u32,
291    power: f64,
292    m: u32,
293    outseq: Option<&[u32]>,
294    outpref: bool,
295    a: f64,
296    directed: bool,
297    seed: u64,
298) -> IgraphResult<Graph> {
299    let outpref = outpref || !directed;
300    validate(nodes, power, m, outseq, outpref, a)?;
301    barabasi_psumtree_inner(nodes, power, m, outseq, outpref, a, directed, seed, true)
302}
303
304#[allow(clippy::too_many_arguments)]
305fn barabasi_psumtree_inner(
306    nodes: u32,
307    power: f64,
308    m: u32,
309    outseq: Option<&[u32]>,
310    outpref: bool,
311    a: f64,
312    directed: bool,
313    seed: u64,
314    allow_multiple: bool,
315) -> IgraphResult<Graph> {
316    let mut graph = Graph::new(nodes, directed)?;
317    if nodes < 2 {
318        return Ok(graph);
319    }
320    if outseq.is_none() && m == 0 {
321        return Ok(graph);
322    }
323    if let Some(seq) = outseq {
324        let after_zero: u64 = seq.iter().skip(1).map(|&x| u64::from(x)).sum();
325        if after_zero == 0 {
326            return Ok(graph);
327        }
328    }
329
330    let n = nodes as usize;
331    let mut rng = SplitMix64::new(seed);
332    let mut psum = PsumTree::new(n);
333    let mut degree: Vec<u32> = vec![0; n];
334    let capacity = edge_capacity(nodes, m, outseq);
335    let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(capacity);
336
337    // Seed vertex: weight 1.0 (matches upstream's
338    // "first vertex weight may be any positive value" — refreshed
339    // properly on step 1).
340    psum.set(0, 1.0);
341
342    // Scratch buffer for per-step picks (used by both variants for the
343    // "refresh chosen weights" pass).
344    let mut picks: Vec<usize> = Vec::with_capacity(m as usize);
345
346    for i in 1..n {
347        let no_of_neighbors = if let Some(seq) = outseq {
348            seq[i] as usize
349        } else {
350            m as usize
351        };
352
353        picks.clear();
354
355        if allow_multiple && no_of_neighbors >= i {
356            // Saturation: cite every existing vertex once.
357            // (Matches upstream's `no_of_neighbors >= i` early branch
358            // in the multiple variant.)
359            for to in 0..i {
360                degree[to] += 1;
361                edges.push((
362                    u32::try_from(i)
363                        .map_err(|_| IgraphError::Internal("vertex index overflows u32"))?,
364                    u32::try_from(to)
365                        .map_err(|_| IgraphError::Internal("vertex index overflows u32"))?,
366                ));
367                psum.set(to, attraction(degree[to], power, a));
368            }
369        } else if allow_multiple {
370            // Multiple variant: snapshot sum once per step; draws may
371            // collide.
372            let sum_snapshot = psum.total();
373            for _ in 0..no_of_neighbors {
374                let to = if sum_snapshot > 0.0 {
375                    let target = rng.gen_unit() * sum_snapshot;
376                    // Bound to [0, i): vertex `i` itself has weight 0
377                    // but lives in the BIT at index i, and FP drift
378                    // can otherwise let binary-lifting overshoot.
379                    psum.search_bounded(target, i)
380                } else {
381                    let span = i as u64;
382                    (rng.next_u64() % span) as usize
383                };
384                degree[to] += 1;
385                edges.push((
386                    u32::try_from(i)
387                        .map_err(|_| IgraphError::Internal("vertex index overflows u32"))?,
388                    u32::try_from(to)
389                        .map_err(|_| IgraphError::Internal("vertex index overflows u32"))?,
390                ));
391                picks.push(to);
392            }
393            // Refresh weights of all chosen vertices (deduped — same
394            // vertex chosen twice only needs one refresh).
395            for &nn in &picks {
396                psum.set(nn, attraction(degree[nn], power, a));
397            }
398        } else {
399            // Simple variant: zero each pick's weight as it is chosen
400            // (so it cannot be sampled again within this step); after
401            // all `no_of_neighbors` draws, refresh each chosen vertex's
402            // weight. Matches upstream: always emit exactly
403            // `no_of_neighbors` edges per step, using the uniform
404            // fallback when the BIT is exhausted (`m >= i` warmup
405            // case can therefore still produce within-step duplicates
406            // via the fallback path).
407            for _ in 0..no_of_neighbors {
408                let sum = psum.total();
409                let to = if sum > 0.0 {
410                    let target = rng.gen_unit() * sum;
411                    // Bound to [0, i): even after several within-step
412                    // zeroings, the BIT may still report positive sum
413                    // due to FP drift; bounding prevents the binary
414                    // lifting from clamping to `self.n - 1` (which
415                    // would equal `i` on the final step).
416                    psum.search_bounded(target, i)
417                } else {
418                    // Fallback path: BIT is exhausted (all candidates
419                    // zeroed this step). Sample uniformly over [0, i).
420                    // For `m >= i` this introduces within-step
421                    // duplicates, matching upstream's behavior.
422                    let span = i as u64;
423                    (rng.next_u64() % span) as usize
424                };
425                degree[to] += 1;
426                edges.push((
427                    u32::try_from(i)
428                        .map_err(|_| IgraphError::Internal("vertex index overflows u32"))?,
429                    u32::try_from(to)
430                        .map_err(|_| IgraphError::Internal("vertex index overflows u32"))?,
431                ));
432                psum.set(to, 0.0);
433                picks.push(to);
434            }
435            // Refresh weights of chosen vertices.
436            for &nn in &picks {
437                psum.set(nn, attraction(degree[nn], power, a));
438            }
439        }
440
441        // Add new vertex `i` to the BIT.
442        if outpref {
443            // Outpref: the new vertex's own out-degree counts. In the
444            // multi-variant saturation branch (m >= i), upstream caps
445            // the increment at `i` since only `i` edges were emitted;
446            // in all other paths the increment is exactly
447            // `no_of_neighbors`.
448            let inc = if allow_multiple && no_of_neighbors >= i {
449                i as u32
450            } else {
451                no_of_neighbors as u32
452            };
453            degree[i] += inc;
454            psum.set(i, attraction(degree[i], power, a));
455        } else {
456            psum.set(i, attraction(0, power, a));
457        }
458    }
459
460    graph.add_edges(edges)?;
461    Ok(graph)
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use std::collections::HashMap;
468
469    fn collect_edges(g: &Graph) -> Vec<(VertexId, VertexId)> {
470        let n = u32::try_from(g.ecount()).expect("ecount fits u32 in tests");
471        (0..n)
472            .map(|eid| g.edge(eid).expect("edge id in bounds"))
473            .collect()
474    }
475
476    #[test]
477    fn psumtree_n_zero_returns_empty() {
478        let g = barabasi_game_psumtree(0, 1.0, 3, None, false, 1.0, true, 1).unwrap();
479        assert_eq!(g.vcount(), 0);
480        assert_eq!(g.ecount(), 0);
481    }
482
483    #[test]
484    fn psumtree_multiple_n_zero_returns_empty() {
485        let g = barabasi_game_psumtree_multiple(0, 1.0, 3, None, false, 1.0, true, 1).unwrap();
486        assert_eq!(g.vcount(), 0);
487        assert_eq!(g.ecount(), 0);
488    }
489
490    #[test]
491    fn psumtree_n_one_singleton() {
492        let g = barabasi_game_psumtree(1, 1.0, 5, None, false, 1.0, true, 1).unwrap();
493        assert_eq!(g.vcount(), 1);
494        assert_eq!(g.ecount(), 0);
495    }
496
497    #[test]
498    fn psumtree_m_zero_edgeless() {
499        let g = barabasi_game_psumtree(10, 1.0, 0, None, false, 1.0, true, 1).unwrap();
500        assert_eq!(g.vcount(), 10);
501        assert_eq!(g.ecount(), 0);
502    }
503
504    #[test]
505    fn psumtree_exact_edge_count() {
506        for &(n, m) in &[(10u32, 1u32), (50, 2), (100, 5)] {
507            let g = barabasi_game_psumtree(n, 1.0, m, None, false, 1.0, true, 0xABCD).unwrap();
508            assert_eq!(g.vcount(), n);
509            assert_eq!(g.ecount(), ((n - 1) * m) as usize);
510        }
511    }
512
513    #[test]
514    fn psumtree_multiple_exact_edge_count() {
515        // Multi variant: when `m >= i` the early-cite branch fires and
516        // emits only `i` edges instead of `m`. Total =
517        // (n-1)*m - m*(m-1)/2 when n > m.
518        for &(n, m) in &[(10u32, 1u32), (50, 2), (100, 5)] {
519            let g =
520                barabasi_game_psumtree_multiple(n, 1.0, m, None, false, 1.0, true, 0xABCD).unwrap();
521            assert_eq!(g.vcount(), n);
522            let expected = (n - 1) * m - m * (m - 1) / 2;
523            assert_eq!(g.ecount(), expected as usize);
524        }
525    }
526
527    #[test]
528    fn psumtree_simple_no_within_step_duplicates_after_warmup() {
529        // Simple variant: once `i > m` the BIT-zeroing guarantees no
530        // within-step duplicates (the uniform fallback only fires when
531        // the BIT is fully exhausted, which can't happen when there
532        // are still un-zeroed candidates).
533        let m: u32 = 4;
534        let g = barabasi_game_psumtree(100, 1.0, m, None, false, 1.0, true, 0xBEEF).unwrap();
535        let edges = collect_edges(&g);
536        let mut by_src: HashMap<VertexId, Vec<VertexId>> = HashMap::new();
537        for &(s, d) in &edges {
538            by_src.entry(s).or_default().push(d);
539        }
540        for (&src, dsts) in &by_src {
541            if src > m {
542                let mut sorted = dsts.clone();
543                sorted.sort_unstable();
544                sorted.dedup();
545                assert_eq!(sorted.len(), dsts.len(), "duplicate at src {src}: {dsts:?}");
546            }
547        }
548    }
549
550    #[test]
551    fn psumtree_simple_no_self_loops() {
552        let g = barabasi_game_psumtree(200, 1.0, 3, None, true, 1.0, true, 0xC001).unwrap();
553        for (s, d) in collect_edges(&g) {
554            assert_ne!(s, d, "self-loop ({s} -> {d}) emitted");
555        }
556    }
557
558    #[test]
559    fn psumtree_multiple_no_self_loops() {
560        let g =
561            barabasi_game_psumtree_multiple(200, 1.0, 3, None, true, 1.0, true, 0xC001).unwrap();
562        for (s, d) in collect_edges(&g) {
563            assert_ne!(s, d, "self-loop ({s} -> {d}) emitted");
564        }
565    }
566
567    #[test]
568    fn psumtree_edges_point_to_earlier_vertices() {
569        let g = barabasi_game_psumtree(50, 1.0, 2, None, false, 1.0, true, 0xBEEF).unwrap();
570        for (s, d) in collect_edges(&g) {
571            assert!(d < s, "edge ({s} -> {d}) violates BA temporal ordering");
572        }
573    }
574
575    #[test]
576    fn psumtree_multiple_edges_point_to_earlier_vertices() {
577        let g =
578            barabasi_game_psumtree_multiple(50, 1.0, 3, None, false, 1.0, true, 0xBEEF).unwrap();
579        for (s, d) in collect_edges(&g) {
580            assert!(d < s, "edge ({s} -> {d}) violates BA temporal ordering");
581        }
582    }
583
584    #[test]
585    fn psumtree_deterministic_with_seed() {
586        let a = barabasi_game_psumtree(80, 1.5, 3, None, false, 1.0, true, 0xDEAD).unwrap();
587        let b = barabasi_game_psumtree(80, 1.5, 3, None, false, 1.0, true, 0xDEAD).unwrap();
588        assert_eq!(collect_edges(&a), collect_edges(&b));
589    }
590
591    #[test]
592    fn psumtree_multiple_deterministic_with_seed() {
593        let a =
594            barabasi_game_psumtree_multiple(80, 1.5, 3, None, false, 1.0, true, 0xDEAD).unwrap();
595        let b =
596            barabasi_game_psumtree_multiple(80, 1.5, 3, None, false, 1.0, true, 0xDEAD).unwrap();
597        assert_eq!(collect_edges(&a), collect_edges(&b));
598    }
599
600    #[test]
601    fn psumtree_different_seeds_differ() {
602        let a = barabasi_game_psumtree(100, 1.0, 2, None, false, 1.0, true, 1).unwrap();
603        let b = barabasi_game_psumtree(100, 1.0, 2, None, false, 1.0, true, 2).unwrap();
604        assert_ne!(collect_edges(&a), collect_edges(&b));
605    }
606
607    #[test]
608    fn psumtree_undirected_forces_outpref() {
609        let g = barabasi_game_psumtree(50, 1.0, 2, None, false, 1.0, false, 0x42).unwrap();
610        assert_eq!(g.vcount(), 50);
611        assert!(!g.is_directed());
612        // Undirected ⇒ outpref forced true ⇒ A=1.0 is fine
613        // (A>=0 with outpref=true).
614        assert_eq!(g.ecount(), 49 * 2);
615    }
616
617    #[test]
618    fn psumtree_undirected_a_zero_outpref_ok() {
619        // With outpref=true (forced by undirected), A=0 should be
620        // accepted (the seed vertex still has positive weight 1.0 to
621        // bootstrap).
622        let g = barabasi_game_psumtree(30, 1.0, 1, None, false, 0.0, false, 0xC0).unwrap();
623        assert_eq!(g.ecount(), 29);
624    }
625
626    #[test]
627    fn psumtree_rejects_a_nonpositive_when_outpref_false() {
628        let err = barabasi_game_psumtree(10, 1.0, 1, None, false, 0.0, true, 1).unwrap_err();
629        match err {
630            IgraphError::InvalidArgument(s) => {
631                assert!(s.contains("A must be positive when outpref is false"));
632            }
633            other => panic!("expected InvalidArgument, got {other:?}"),
634        }
635    }
636
637    #[test]
638    fn psumtree_rejects_a_negative_when_outpref_true() {
639        let err = barabasi_game_psumtree(10, 1.0, 1, None, true, -0.5, true, 1).unwrap_err();
640        match err {
641            IgraphError::InvalidArgument(s) => {
642                assert!(s.contains("A must be non-negative when outpref is true"));
643            }
644            other => panic!("expected InvalidArgument, got {other:?}"),
645        }
646    }
647
648    #[test]
649    fn psumtree_rejects_power_nan() {
650        let err = barabasi_game_psumtree(10, f64::NAN, 1, None, false, 1.0, true, 1).unwrap_err();
651        match err {
652            IgraphError::InvalidArgument(s) => assert!(s.contains("power must be finite")),
653            other => panic!("expected InvalidArgument, got {other:?}"),
654        }
655    }
656
657    #[test]
658    fn psumtree_rejects_a_inf() {
659        let err =
660            barabasi_game_psumtree(10, 1.0, 1, None, true, f64::INFINITY, true, 1).unwrap_err();
661        match err {
662            IgraphError::InvalidArgument(s) => assert!(s.contains("A must be finite")),
663            other => panic!("expected InvalidArgument, got {other:?}"),
664        }
665    }
666
667    #[test]
668    fn psumtree_rejects_outseq_length_mismatch() {
669        let seq = vec![0u32, 1, 2];
670        let err = barabasi_game_psumtree(10, 1.0, 1, Some(&seq), false, 1.0, true, 1).unwrap_err();
671        match err {
672            IgraphError::InvalidArgument(s) => assert!(s.contains("outseq length")),
673            other => panic!("expected InvalidArgument, got {other:?}"),
674        }
675    }
676
677    #[test]
678    fn psumtree_outseq_overrides_m() {
679        let seq = vec![0u32, 1, 1, 2, 3];
680        let g = barabasi_game_psumtree(5, 1.0, 99, Some(&seq), false, 1.0, true, 0).unwrap();
681        assert_eq!(g.vcount(), 5);
682        // expected edges = sum(seq[1..]) = 1+1+2+3 = 7
683        assert_eq!(g.ecount(), 7);
684    }
685
686    #[test]
687    fn psumtree_multiple_outseq_overrides_m() {
688        let seq = vec![0u32, 1, 1, 2, 3];
689        let g =
690            barabasi_game_psumtree_multiple(5, 1.0, 99, Some(&seq), false, 1.0, true, 0).unwrap();
691        assert_eq!(g.vcount(), 5);
692        assert_eq!(g.ecount(), 7);
693    }
694
695    #[test]
696    fn psumtree_first_vertex_never_source() {
697        let g = barabasi_game_psumtree(50, 1.0, 2, None, false, 1.0, true, 0x42).unwrap();
698        for (s, _) in collect_edges(&g) {
699            assert_ne!(s, 0);
700        }
701    }
702
703    #[test]
704    fn psumtree_concentration_around_early_vertices() {
705        let g = barabasi_game_psumtree(500, 1.0, 3, None, false, 1.0, true, 0xABCD).unwrap();
706        let mut deg = vec![0u32; g.vcount() as usize];
707        for (s, d) in collect_edges(&g) {
708            deg[s as usize] += 1;
709            deg[d as usize] += 1;
710        }
711        let mut indexed: Vec<(usize, u32)> = deg.iter().copied().enumerate().collect();
712        indexed.sort_by_key(|&(_, d)| std::cmp::Reverse(d));
713        let top5: Vec<usize> = indexed.iter().take(5).map(|(v, _)| *v).collect();
714        assert!(
715            top5.contains(&0),
716            "expected vertex 0 in top-5, got {top5:?}"
717        );
718    }
719
720    #[test]
721    fn psumtree_high_power_super_concentration() {
722        // Power = 2 ⇒ rich-get-richer is super-linear ⇒ vertex 0
723        // dominates strongly.
724        let g = barabasi_game_psumtree(300, 2.0, 1, None, false, 1.0, true, 0xF00D).unwrap();
725        let mut deg = vec![0u32; g.vcount() as usize];
726        for (s, d) in collect_edges(&g) {
727            deg[s as usize] += 1;
728            deg[d as usize] += 1;
729        }
730        let max_deg = *deg.iter().max().unwrap();
731        let total: u32 = deg.iter().sum();
732        let max_v = deg.iter().position(|&d| d == max_deg).unwrap();
733        // Top vertex should hold a substantial chunk of total degree
734        // and should be a very early vertex.
735        assert!(
736            max_deg >= total / 10,
737            "max_deg {max_deg} too small vs total {total}"
738        );
739        assert!(max_v < 30, "top vertex {max_v} should be among earliest");
740    }
741}
742
743#[cfg(all(test, feature = "proptest-harness"))]
744mod proptests {
745    use super::*;
746    use proptest::prelude::*;
747
748    proptest! {
749        #[test]
750        fn psumtree_no_self_loops(
751            n in 2u32..120,
752            m in 1u32..6,
753            power in 0.0f64..3.0,
754            seed: u64,
755            outpref: bool,
756        ) {
757            // power >= 0 keeps weights finite; with A=1 zero-degree
758            // vertices still have non-zero weight, satisfying both
759            // outpref=false and outpref=true contracts.
760            let g = barabasi_game_psumtree(n, power, m, None, outpref, 1.0, true, seed)
761                .expect("valid params");
762            let m_e = u32::try_from(g.ecount()).expect("ecount fits u32 in tests");
763            for eid in 0..m_e {
764                let (s, d) = g.edge(eid).expect("edge in bounds");
765                prop_assert_ne!(s, d);
766            }
767        }
768
769        #[test]
770        fn psumtree_multiple_no_self_loops(
771            n in 2u32..120,
772            m in 1u32..6,
773            power in 0.0f64..3.0,
774            seed: u64,
775            outpref: bool,
776        ) {
777            let g = barabasi_game_psumtree_multiple(n, power, m, None, outpref, 1.0, true, seed)
778                .expect("valid params");
779            let m_e = u32::try_from(g.ecount()).expect("ecount fits u32 in tests");
780            for eid in 0..m_e {
781                let (s, d) = g.edge(eid).expect("edge in bounds");
782                prop_assert_ne!(s, d);
783            }
784        }
785
786        #[test]
787        fn psumtree_exact_edge_count_prop(
788            n in 2u32..200,
789            m in 1u32..5,
790            seed: u64,
791        ) {
792            let g = barabasi_game_psumtree(n, 1.0, m, None, false, 1.0, true, seed)
793                .expect("valid params");
794            prop_assert_eq!(g.ecount() as u64, u64::from((n - 1) * m));
795        }
796
797        #[test]
798        fn psumtree_multiple_exact_edge_count_prop(
799            n in 2u32..200,
800            m in 1u32..5,
801            seed: u64,
802        ) {
803            // Multi variant saturates when m >= i: total =
804            // (n-1)*m - m*(m-1)/2 when n > m, else triangular sum.
805            let g = barabasi_game_psumtree_multiple(n, 1.0, m, None, false, 1.0, true, seed)
806                .expect("valid params");
807            let expected: u64 = if n > m {
808                u64::from((n - 1) * m) - u64::from(m * (m - 1) / 2)
809            } else {
810                // n <= m: every step i in [1, n-1] saturates to i
811                // edges. Sum = (n-1)*n/2.
812                u64::from((n - 1) * n / 2)
813            };
814            prop_assert_eq!(g.ecount() as u64, expected);
815        }
816
817        #[test]
818        fn psumtree_simple_no_within_step_dups_after_warmup(
819            n in 10u32..80,
820            m in 2u32..5,
821            seed: u64,
822        ) {
823            // For src > m the BIT-zeroing path guarantees distinct dst.
824            let g = barabasi_game_psumtree(n, 1.0, m, None, false, 1.0, true, seed)
825                .expect("valid params");
826            let m_e = u32::try_from(g.ecount()).expect("ecount fits u32 in tests");
827            let mut by_src: std::collections::HashMap<VertexId, Vec<VertexId>> = std::collections::HashMap::new();
828            for eid in 0..m_e {
829                let (s, d) = g.edge(eid).expect("edge in bounds");
830                by_src.entry(s).or_default().push(d);
831            }
832            for (&src, dsts) in &by_src {
833                if src > m {
834                    let mut sorted = dsts.clone();
835                    sorted.sort_unstable();
836                    let total = sorted.len();
837                    sorted.dedup();
838                    prop_assert_eq!(sorted.len(), total,
839                        "duplicate dsts at src {}: {:?}", src, dsts);
840                }
841            }
842        }
843    }
844}