Skip to main content

rust_igraph/algorithms/games/
lastcit.rs

1//! Last-citation game (ALGO-GN-018).
2//!
3//! Counterpart of `igraph_lastcit_game()` from
4//! `references/igraph/src/games/citations.c:91-209`. Models an
5//! evolving citation network where a vertex's attractivity decays with
6//! the time elapsed since its last citation, binned into `agebins` age
7//! buckets.
8//!
9//! ## Algorithm
10//!
11//! Time is measured by vertex arrival; `binwidth = nodes / agebins + 1`.
12//! Every vertex `v` carries a weight in a prefix-sum tree:
13//!
14//! 1. Vertex `0` enters with the "never-cited" weight `preference[agebins]`.
15//! 2. For each step `i ∈ [1, nodes)`:
16//!    - For each of `edges_per_node` outgoing edges, draw `u ∈ [0, sum)`
17//!      uniformly and `psumtree.search(u)` returns the cited vertex `to`.
18//!      Record `lastcit[to] = i + 1`, bump `to`'s weight to
19//!      `preference[0]` (just-cited bucket).
20//!    - When `sum == 0` (no positive weight anywhere — only possible if
21//!      `preference[agebins]` *and* all `preference[..agebins]` happen
22//!      to be zero, but the validator already rejects that), the C
23//!      reference falls back to `RNG_INTEGER(0, i-1)`; we mirror it.
24//!    - Insert vertex `i` with weight `preference[agebins]`.
25//!    - **Age sweep**: for each `k ≥ 1` such that `shnode = i - k·binwidth ≥ 1`,
26//!      walk `shnode`'s emitted citations and, for every cited vertex
27//!      `c` whose `lastcit[c] == shnode + 1` (i.e. its last citation
28//!      really was at `shnode`), demote its weight to `preference[k]`.
29//!
30//! The structure is a binary-indexed (Fenwick) tree with binary-lifting
31//! prefix-search, giving O(log n) per update / search.
32//!
33//! ## Self-loops & multi-edges
34//!
35//! * **Self-loops**: impossible. `psumtree.search` and the `sum=0`
36//!   fallback both operate over the `[0, i)` index range; vertex `i` is
37//!   added *after* its outgoing edges are drawn, so it cannot be picked.
38//! * **Multi-edges**: yes, when `edges_per_node ≥ 2`. Two draws at the
39//!   same step can land on the same target. Caller should `simplify()`
40//!   if simple output is required (matches upstream behaviour).
41//!
42//! ## Determinism
43//!
44//! Reproducible against the shared `SplitMix64`
45//! PRNG. Not bit-portable to upstream igraph's GLIBC RNG; conformance
46//! asserts structural invariants only.
47//!
48//! ## References
49//!
50//! * Upstream igraph C `igraph_lastcit_game`, MIT-licensed reference port.
51
52#![allow(
53    unknown_lints,
54    clippy::cast_possible_truncation,
55    clippy::cast_precision_loss,
56    clippy::cast_sign_loss,
57    clippy::float_cmp,
58    clippy::too_many_arguments,
59    clippy::similar_names,
60    clippy::many_single_char_names
61)]
62
63use crate::core::rng::SplitMix64;
64use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
65
66/// Fenwick-tree-based prefix-sum store with O(log n) point set and
67/// O(log n) prefix-target search.
68///
69/// `values[i]` is the current weight at index `i`; `bit[i]` is the
70/// Fenwick storage (1-indexed). `total` is maintained eagerly so callers
71/// don't need to query when checking the `sum == 0` branch.
72struct PsumTree {
73    n: usize,
74    bit: Vec<f64>,
75    values: Vec<f64>,
76    total: f64,
77}
78
79impl PsumTree {
80    fn new(n: usize) -> Self {
81        Self {
82            n,
83            bit: vec![0.0; n + 1],
84            values: vec![0.0; n],
85            total: 0.0,
86        }
87    }
88
89    /// Set `values[i] = v`; updates the Fenwick storage and `total` by
90    /// the delta `v - values[i]`.
91    fn set(&mut self, i: usize, v: f64) {
92        let delta = v - self.values[i];
93        self.values[i] = v;
94        self.total += delta;
95        let mut k = i + 1;
96        while k <= self.n {
97            self.bit[k] += delta;
98            k += k & k.wrapping_neg();
99        }
100    }
101
102    fn total(&self) -> f64 {
103        self.total
104    }
105
106    /// Smallest index `i` such that `prefix_sum(i+1) > target` for
107    /// `target ∈ [0, total)`. Returns `n - 1` on numerical edge cases
108    /// (target very close to total) — the caller guarantees
109    /// `target = u * total` with `u ∈ [0, 1)`.
110    fn search(&self, target: f64) -> usize {
111        if self.n == 0 {
112            return 0;
113        }
114        let mut idx: usize = 0;
115        let mut remaining = target;
116        let mut step = (self.n + 1).next_power_of_two() >> 1;
117        while step > 0 {
118            let next = idx + step;
119            if next <= self.n && self.bit[next] <= remaining {
120                idx = next;
121                remaining -= self.bit[next];
122            }
123            step >>= 1;
124        }
125        // `idx` is the largest position where prefix_sum is still ≤ target,
126        // so the smallest position with prefix_sum > target is `idx + 1`,
127        // which maps to 0-based vertex `idx`.
128        idx.min(self.n - 1)
129    }
130}
131
132fn validate(nodes: u32, edges_per_node: u32, agebins: u32, preference: &[f64]) -> IgraphResult<()> {
133    if agebins < 1 {
134        return Err(IgraphError::InvalidArgument(format!(
135            "agebins must be at least 1 (got {agebins})"
136        )));
137    }
138    let required = (agebins as usize)
139        .checked_add(1)
140        .ok_or_else(|| IgraphError::InvalidArgument("agebins overflow".into()))?;
141    if preference.len() != required {
142        return Err(IgraphError::InvalidArgument(format!(
143            "preference vector length ({}) must equal agebins + 1 = {required}",
144            preference.len()
145        )));
146    }
147    for (i, &p) in preference.iter().enumerate() {
148        if p.is_nan() {
149            return Err(IgraphError::InvalidArgument(format!(
150                "preference[{i}] is NaN"
151            )));
152        }
153        if !p.is_finite() {
154            return Err(IgraphError::InvalidArgument(format!(
155                "preference[{i}] is not finite (got {p})"
156            )));
157        }
158        if p < 0.0 {
159            return Err(IgraphError::InvalidArgument(format!(
160                "preference[{i}] is negative ({p})"
161            )));
162        }
163    }
164    if preference[agebins as usize] <= 0.0 {
165        return Err(IgraphError::InvalidArgument(format!(
166            "preference[agebins] (never-cited weight) must be strictly positive, got {}",
167            preference[agebins as usize]
168        )));
169    }
170    let _ = (nodes, edges_per_node);
171    Ok(())
172}
173
174/// Last-citation citation-network game. See module docs.
175///
176/// # Examples
177///
178/// ```
179/// use rust_igraph::lastcit_game;
180///
181/// // 5 vertices, 1 edge per step, 2 age bins.
182/// // preference: [bin0, bin1, never_cited] — 3 entries.
183/// let g = lastcit_game(5, 1, 2, &[1.0, 0.5, 0.1], true, 42).unwrap();
184/// assert_eq!(g.vcount(), 5);
185/// assert_eq!(g.ecount(), 4);
186/// ```
187pub fn lastcit_game(
188    nodes: u32,
189    edges_per_node: u32,
190    agebins: u32,
191    preference: &[f64],
192    directed: bool,
193    seed: u64,
194) -> IgraphResult<Graph> {
195    validate(nodes, edges_per_node, agebins, preference)?;
196
197    let mut graph = Graph::new(nodes, directed)?;
198    if nodes == 0 || edges_per_node == 0 || nodes < 2 {
199        return Ok(graph);
200    }
201
202    let n = nodes as usize;
203    let eps = edges_per_node as usize;
204    let agebins_u = agebins as usize;
205    let binwidth = n / agebins_u + 1;
206
207    let mut rng = SplitMix64::new(seed);
208    let mut psum = PsumTree::new(n);
209    // lastcit[v] = (step at which v was last cited) + 1; 0 means "never".
210    let mut lastcit: Vec<u32> = vec![0; n];
211
212    // Vertex 0 starts in the "never cited" bin.
213    psum.set(0, preference[agebins_u]);
214
215    let edges_capacity = (n - 1).saturating_mul(eps);
216    let mut edges: Vec<(VertexId, VertexId)> = Vec::with_capacity(edges_capacity);
217
218    for i in 1..n {
219        // Draw `eps` citations from currently-alive vertices [0, i).
220        for _ in 0..eps {
221            let sum = psum.total();
222            let to = if sum > 0.0 {
223                let target = rng.gen_unit() * sum;
224                psum.search(target)
225            } else {
226                // Fallback: uniform over [0, i-1].
227                let span = i as u64;
228                (rng.next_u64() % span) as usize
229            };
230            let src = u32::try_from(i)
231                .map_err(|_| IgraphError::InvalidArgument("vertex index overflow".into()))?;
232            let dst = u32::try_from(to)
233                .map_err(|_| IgraphError::InvalidArgument("vertex index overflow".into()))?;
234            edges.push((src, dst));
235            // Cited at step i+1 (1-based to distinguish from 0 = never).
236            let i_plus_one = u32::try_from(i + 1)
237                .map_err(|_| IgraphError::InvalidArgument("step counter overflow".into()))?;
238            lastcit[to] = i_plus_one;
239            psum.set(to, preference[0]);
240        }
241
242        // Insert vertex i in the "never cited" bin.
243        psum.set(i, preference[agebins_u]);
244
245        // Age sweep: for every previously-emitted citation that originated
246        // at vertex `shnode = i - k*binwidth` (with k ≥ 1, shnode ≥ 1), if
247        // the cited vertex's last citation is still that one, demote its
248        // weight to bucket `k`. Bucket `agebins` would mean "never-cited
249        // again" — but if lastcit > 0 the vertex *has* been cited at least
250        // once, so the maximum live bucket here is `min(k, agebins - 1)`.
251        let mut k: usize = 1;
252        while k * binwidth <= i.saturating_sub(1) + binwidth {
253            let lhs = i.saturating_sub(k * binwidth);
254            if lhs < 1 {
255                break;
256            }
257            let shnode = lhs;
258            // Edges emitted at step `shnode` occupy indices
259            // [(shnode-1)*eps, shnode*eps) in `edges`.
260            let start = (shnode - 1) * eps;
261            let end = shnode * eps;
262            let shnode_marker = u32::try_from(shnode + 1)
263                .map_err(|_| IgraphError::InvalidArgument("step counter overflow".into()))?;
264            // Pick the demotion bucket: bucket index `k` saturates at
265            // `agebins - 1`; the `agebins` slot is reserved for the
266            // never-cited case which doesn't apply here (these vertices
267            // have been cited).
268            let bucket = k.min(agebins_u - 1);
269            let new_weight = preference[bucket];
270            for edge in &edges[start..end] {
271                let cnode = edge.1 as usize;
272                if lastcit[cnode] == shnode_marker {
273                    psum.set(cnode, new_weight);
274                }
275            }
276            k += 1;
277        }
278    }
279
280    graph.add_edges(edges)?;
281    Ok(graph)
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn nodes_zero_returns_empty_graph() {
290        let g = lastcit_game(0, 3, 2, &[1.0, 0.5, 0.1], false, 1).unwrap();
291        assert_eq!(g.vcount(), 0);
292        assert_eq!(g.ecount(), 0);
293    }
294
295    #[test]
296    fn nodes_one_returns_single_vertex_no_edges() {
297        let g = lastcit_game(1, 5, 2, &[1.0, 0.5, 0.1], false, 2).unwrap();
298        assert_eq!(g.vcount(), 1);
299        assert_eq!(g.ecount(), 0);
300    }
301
302    #[test]
303    fn edges_per_node_zero_yields_edgeless() {
304        let g = lastcit_game(50, 0, 2, &[1.0, 0.5, 0.1], false, 3).unwrap();
305        assert_eq!(g.vcount(), 50);
306        assert_eq!(g.ecount(), 0);
307    }
308
309    #[test]
310    fn exact_ecount() {
311        let n = 40u32;
312        let eps = 3u32;
313        let g = lastcit_game(n, eps, 3, &[1.0, 0.5, 0.2, 0.1], false, 4).unwrap();
314        assert_eq!(g.vcount(), n);
315        assert_eq!(g.ecount(), ((n - 1) * eps) as usize);
316    }
317
318    #[test]
319    fn determinism() {
320        let pref = vec![1.0, 0.7, 0.3, 0.1];
321        let g1 = lastcit_game(50, 3, 3, &pref, true, 12345).unwrap();
322        let g2 = lastcit_game(50, 3, 3, &pref, true, 12345).unwrap();
323        assert_eq!(g1.ecount(), g2.ecount());
324        let n_e = u32::try_from(g1.ecount()).unwrap();
325        for eid in 0..n_e {
326            assert_eq!(g1.edge(eid).unwrap(), g2.edge(eid).unwrap());
327        }
328    }
329
330    #[test]
331    fn different_seeds_diverge() {
332        let pref = vec![1.0, 0.5, 0.1];
333        let g1 = lastcit_game(40, 2, 2, &pref, false, 1).unwrap();
334        let g2 = lastcit_game(40, 2, 2, &pref, false, 2).unwrap();
335        let n_e = u32::try_from(g1.ecount()).unwrap();
336        let mut any_diff = false;
337        for eid in 0..n_e {
338            if g1.edge(eid).unwrap() != g2.edge(eid).unwrap() {
339                any_diff = true;
340                break;
341            }
342        }
343        assert!(any_diff);
344    }
345
346    #[test]
347    fn no_self_loops() {
348        let pref = vec![1.0, 0.5, 0.1];
349        let g = lastcit_game(80, 3, 2, &pref, true, 999).unwrap();
350        let n_e = u32::try_from(g.ecount()).unwrap();
351        for eid in 0..n_e {
352            let (a, b) = g.edge(eid).unwrap();
353            assert_ne!(a, b, "lastcit game must never self-loop");
354        }
355    }
356
357    #[test]
358    fn src_in_step_order() {
359        let pref = vec![1.0, 0.5];
360        let n = 20u32;
361        let eps = 3u32;
362        let g = lastcit_game(n, eps, 1, &pref, true, 7).unwrap();
363        let n_e = u32::try_from(g.ecount()).unwrap();
364        for eid in 0..n_e {
365            let (src, _dst) = g.edge(eid).unwrap();
366            let expected_src = 1 + eid / eps;
367            assert_eq!(src, expected_src);
368        }
369    }
370
371    #[test]
372    fn dst_strictly_less_than_src() {
373        let pref = vec![1.0, 0.5, 0.1];
374        let n = 50u32;
375        let g = lastcit_game(n, 2, 2, &pref, true, 11).unwrap();
376        let n_e = u32::try_from(g.ecount()).unwrap();
377        for eid in 0..n_e {
378            let (src, dst) = g.edge(eid).unwrap();
379            assert!(dst < src);
380        }
381    }
382
383    #[test]
384    fn directed_flag_propagates() {
385        let pref = vec![1.0, 0.5];
386        let g = lastcit_game(20, 2, 1, &pref, true, 11).unwrap();
387        assert!(g.is_directed());
388    }
389
390    #[test]
391    fn undirected_flag_propagates() {
392        let pref = vec![1.0, 0.5];
393        let g = lastcit_game(20, 2, 1, &pref, false, 12).unwrap();
394        assert!(!g.is_directed());
395    }
396
397    #[test]
398    fn err_agebins_zero() {
399        let err = lastcit_game(10, 1, 0, &[1.0], false, 1);
400        assert!(err.is_err());
401    }
402
403    #[test]
404    fn err_preference_length_mismatch() {
405        let err = lastcit_game(10, 1, 3, &[1.0, 0.5], false, 1);
406        assert!(err.is_err());
407    }
408
409    #[test]
410    fn err_never_cited_weight_zero() {
411        let err = lastcit_game(10, 1, 2, &[1.0, 0.5, 0.0], false, 1);
412        assert!(err.is_err());
413    }
414
415    #[test]
416    fn err_preference_negative() {
417        let err = lastcit_game(10, 1, 2, &[1.0, -0.5, 1.0], false, 1);
418        assert!(err.is_err());
419    }
420
421    #[test]
422    fn err_preference_nan() {
423        let err = lastcit_game(10, 1, 2, &[1.0, f64::NAN, 1.0], false, 1);
424        assert!(err.is_err());
425    }
426
427    #[test]
428    fn err_preference_inf() {
429        let err = lastcit_game(10, 1, 2, &[1.0, 0.5, f64::INFINITY], false, 1);
430        assert!(err.is_err());
431    }
432
433    #[test]
434    fn only_never_cited_weight_positive_concentrates_on_uncited() {
435        // preference = [0, 0, 0, 0, 1]: only the never-cited bucket
436        // carries weight. Every citation goes to a not-yet-cited vertex.
437        // After being cited once, a vertex's weight drops to 0, so the
438        // psumtree never re-picks it. Hence the resulting graph is
439        // simple (no multi-edges among the existing pool — each vertex
440        // can only be cited at most `degree(v) = #times it appears as
441        // dst within a single source's eps draws` times, but multiple
442        // draws within the SAME step still hit the same uncited vertex
443        // only after intermediate updates... actually for eps=1 every
444        // dst is unique).
445        let pref = vec![0.0, 0.0, 0.0, 0.0, 1.0];
446        let n = 50u32;
447        let g = lastcit_game(n, 1, 4, &pref, true, 17).unwrap();
448        let n_e = u32::try_from(g.ecount()).unwrap();
449        // Count: vertex must have been "fresh" (never cited) at the time
450        // it was cited. Equivalent statement: each dst appears at most
451        // once across the whole edge list.
452        let mut seen = vec![false; n as usize];
453        for eid in 0..n_e {
454            let (_s, d) = g.edge(eid).unwrap();
455            assert!(
456                !seen[d as usize],
457                "vertex {d} cited twice under never-cited-only preference"
458            );
459            seen[d as usize] = true;
460        }
461    }
462
463    #[test]
464    fn high_recent_weight_concentrates_in_degree_on_recent_vertices() {
465        // preference = [1000, 0.001, 0.001]: just-cited vertices are
466        // overwhelmingly preferred. After the warm-up phase, in-degree
467        // should be very concentrated on the most recently cited pool.
468        let pref = vec![1000.0, 0.001, 0.001];
469        let n = 200u32;
470        let g = lastcit_game(n, 3, 2, &pref, true, 31).unwrap();
471        let n_e = u32::try_from(g.ecount()).unwrap();
472        let mut in_deg = vec![0u32; n as usize];
473        for eid in 0..n_e {
474            let (_s, d) = g.edge(eid).unwrap();
475            in_deg[d as usize] += 1;
476        }
477        let max_in = *in_deg.iter().max().unwrap();
478        // With ~600 edges and heavy concentration, the top vertex should
479        // accumulate well above the uniform mean of ~3.
480        assert!(
481            max_in > 10,
482            "expected concentration, got max in-degree {max_in}"
483        );
484    }
485}
486
487#[cfg(all(test, feature = "proptest-harness"))]
488mod proptests {
489    use super::*;
490    use proptest::prelude::*;
491
492    proptest! {
493        #[test]
494        fn ecount_exact(
495            n in 2u32..40,
496            eps in 1u32..5,
497            seed in any::<u64>(),
498        ) {
499            let pref = vec![1.0, 0.5, 0.1];
500            let g = lastcit_game(n, eps, 2, &pref, false, seed).unwrap();
501            prop_assert_eq!(g.vcount(), n);
502            prop_assert_eq!(g.ecount(), ((n - 1) * eps) as usize);
503        }
504
505        #[test]
506        fn no_self_loops(
507            n in 2u32..30,
508            eps in 1u32..4,
509            seed in any::<u64>(),
510        ) {
511            let pref = vec![1.0, 0.5];
512            let g = lastcit_game(n, eps, 1, &pref, true, seed).unwrap();
513            let n_e = u32::try_from(g.ecount()).unwrap();
514            for eid in 0..n_e {
515                let (a, b) = g.edge(eid).unwrap();
516                prop_assert_ne!(a, b);
517            }
518        }
519
520        #[test]
521        fn src_step_order(
522            n in 2u32..25,
523            eps in 1u32..4,
524            seed in any::<u64>(),
525        ) {
526            let pref = vec![1.0, 0.5];
527            let g = lastcit_game(n, eps, 1, &pref, true, seed).unwrap();
528            let n_e = u32::try_from(g.ecount()).unwrap();
529            for eid in 0..n_e {
530                let (src, _dst) = g.edge(eid).unwrap();
531                let expected_src = 1 + eid / eps;
532                prop_assert_eq!(src, expected_src);
533            }
534        }
535
536        #[test]
537        fn dst_lt_src(
538            n in 2u32..25,
539            eps in 1u32..4,
540            seed in any::<u64>(),
541        ) {
542            let pref = vec![1.0, 0.5, 0.1];
543            let g = lastcit_game(n, eps, 2, &pref, true, seed).unwrap();
544            let n_e = u32::try_from(g.ecount()).unwrap();
545            for eid in 0..n_e {
546                let (src, dst) = g.edge(eid).unwrap();
547                prop_assert!(dst < src);
548            }
549        }
550
551        #[test]
552        fn determinism_under_proptest(
553            n in 2u32..30,
554            eps in 1u32..4,
555            agebins in 1u32..4,
556            seed in any::<u64>(),
557        ) {
558            let pref: Vec<f64> = (0..=agebins as usize)
559                .map(|i| 1.0 / (i as f64 + 1.0))
560                .collect();
561            let g1 = lastcit_game(n, eps, agebins, &pref, false, seed).unwrap();
562            let g2 = lastcit_game(n, eps, agebins, &pref, false, seed).unwrap();
563            prop_assert_eq!(g1.ecount(), g2.ecount());
564            let n_e = u32::try_from(g1.ecount()).unwrap();
565            for eid in 0..n_e {
566                prop_assert_eq!(g1.edge(eid).unwrap(), g2.edge(eid).unwrap());
567            }
568        }
569    }
570}