Skip to main content

rust_igraph/algorithms/games/
forestfire.rs

1//! Forest fire random-graph generator (ALGO-GN-006).
2//!
3//! Counterpart of `igraph_forest_fire_game()` in
4//! `references/igraph/src/games/forestfire.c:106-257`.
5//!
6//! ## Model
7//!
8//! Leskovec–Kleinberg–Faloutsos (KDD'05). Nodes are added one at a
9//! time. Each new node `v`:
10//!
11//! 1. picks `ambs` *ambassador* vertices uniformly at random from the
12//!    already-present nodes and cites each of them;
13//! 2. for every ambassador `a`, draws `x ~ Geom(1 − p)` outgoing and
14//!    `y ~ Geom(1 − r·p)` incoming neighbours of `a` (where `p` is the
15//!    forward burning probability and `r` the backward factor), cites
16//!    those still-unvisited neighbours, and pushes them onto the burn
17//!    queue;
18//! 3. the burn continues breadth-first until the queue empties.
19//!
20//! The corrected variant from
21//! <https://www.cs.cmu.edu/~jure/pubs/powergrowth-tkdd.pdf> is the one
22//! mirrored here — the published paper algorithm uses
23//! `mean = p/(1−p)` which igraph corrects to a single geometric draw.
24//!
25//! ## Validation
26//!
27//! * `fw_prob ∈ [0, 1)` — strict upper bound so the geometric draw is
28//!   finite (geometric mean is `p/(1−p)`).
29//! * `bw_factor · fw_prob ∈ [0, 1)` — same guard for the incoming draw.
30//! * `ambs ≥ 0`. `ambs = 0` returns an edgeless graph of `n` isolated
31//!   vertices, matching upstream.
32//!
33//! ## Algorithm cost
34//!
35//! Asymptotic cost is hard to pin down because the burn radius depends
36//! on the in/out-neighbourhood sizes built up so far. In the sparse
37//! regime (`p` away from 1) the expected work per new node is bounded
38//! by the geometric tail, giving roughly `O(n / (1 − p)²)` overall.
39//! Memory is `O(n + |E|)`: two per-vertex `Vec<u32>` adjacency arrays
40//! plus the edge buffer.
41//!
42//! ## Determinism
43//!
44//! Fully deterministic in `(n, fw_prob, bw_factor, ambs, directed,
45//! seed)` via `SplitMix64`.
46
47#![allow(
48    clippy::cast_possible_truncation,
49    clippy::cast_sign_loss,
50    clippy::cast_precision_loss
51)]
52
53use std::collections::VecDeque;
54
55use crate::core::rng::SplitMix64;
56use crate::core::{Graph, IgraphError, IgraphResult, VertexId};
57
58fn validate(fw_prob: f64, bw_factor: f64) -> IgraphResult<()> {
59    if !fw_prob.is_finite() || !(0.0..1.0).contains(&fw_prob) {
60        return Err(IgraphError::Internal(
61            "forest_fire_game: fw_prob must satisfy 0 <= fw_prob < 1",
62        ));
63    }
64    let bw_prob = bw_factor * fw_prob;
65    if !bw_prob.is_finite() || !(0.0..1.0).contains(&bw_prob) {
66        return Err(IgraphError::Internal(
67            "forest_fire_game: bw_factor * fw_prob must satisfy 0 <= bw*fw < 1",
68        ));
69    }
70    Ok(())
71}
72
73/// Generate a forest-fire-model random graph on `n` vertices.
74///
75/// * `n` — vertex count. `n = 0` returns an empty graph; `n = 1`
76///   returns a single isolated vertex.
77/// * `fw_prob` — forward burning probability `p ∈ [0, 1)`. The number
78///   of outgoing neighbours a burn picks from each ambassador follows
79///   `Geom(1 − p)`.
80/// * `bw_factor` — backward burning ratio `r ≥ 0`. The number of
81///   incoming neighbours follows `Geom(1 − r·p)`; the product
82///   `r · p` must also stay in `[0, 1)`.
83/// * `ambs` — number of ambassadors each new node connects to.
84///   `ambs = 0` shortcuts to an edgeless `n`-vertex graph.
85/// * `directed` — whether to return a directed graph. The internal
86///   burn tracks in/out-neighbour lists regardless of this flag; only
87///   the final `Graph` honours it.
88/// * `seed` — seeds the internal `SplitMix64` PRNG.
89///
90/// # Errors
91///
92/// Returns [`IgraphError::Internal`] if `fw_prob` falls outside
93/// `[0, 1)`, if `bw_factor · fw_prob` is outside `[0, 1)`, or if
94/// either parameter is non-finite.
95///
96/// # Examples
97///
98/// ```
99/// use rust_igraph::forest_fire_game;
100///
101/// // Light burn: small fw_prob keeps edges close to the ambassador count.
102/// let g = forest_fire_game(100, 0.05, 0.3, 2, true, 0xF00D).unwrap();
103/// assert_eq!(g.vcount(), 100);
104/// assert!(g.is_directed());
105/// // Each non-root vertex cites >= 1 ambassador, so at least n - 1 edges.
106/// assert!(g.ecount() >= 99);
107/// ```
108pub fn forest_fire_game(
109    n: u32,
110    fw_prob: f64,
111    bw_factor: f64,
112    ambs: u32,
113    directed: bool,
114    seed: u64,
115) -> IgraphResult<Graph> {
116    validate(fw_prob, bw_factor)?;
117
118    if ambs == 0 || n < 2 {
119        return Graph::new(n, directed);
120    }
121
122    let n_usize = n as usize;
123    let p_geom_out = 1.0 - fw_prob;
124    let p_geom_in = 1.0 - fw_prob * bw_factor;
125
126    let mut rng = SplitMix64::new(seed);
127
128    // Per-vertex adjacency lists built up incrementally — used by the
129    // burn to choose where to spread next.
130    let mut inneis: Vec<Vec<u32>> = (0..n_usize).map(|_| Vec::new()).collect();
131    let mut outneis: Vec<Vec<u32>> = (0..n_usize).map(|_| Vec::new()).collect();
132
133    // `visited[v] == actnode + 1` flags `v` as "already cited by the
134    // current new node `actnode`". Stamping with `actnode + 1` rather
135    // than `actnode` ensures the zero-init slate is distinguishable
136    // from a stamp left by `actnode == 0`.
137    let mut visited: Vec<u32> = vec![0; n_usize];
138    let mut edges: Vec<(VertexId, VertexId)> = Vec::new();
139    let mut neiq: VecDeque<u32> = VecDeque::with_capacity(16);
140
141    for actnode in 1u32..n {
142        let stamp = actnode.wrapping_add(1);
143        // The new vertex never cites itself.
144        visited[actnode as usize] = stamp;
145
146        // Pick `ambs` ambassadors uniformly from [0, actnode).
147        for _ in 0..ambs {
148            let a = rng.gen_index(actnode as usize) as u32;
149            try_add_edge(
150                &mut visited,
151                &mut neiq,
152                &mut edges,
153                &mut outneis,
154                &mut inneis,
155                actnode,
156                a,
157                stamp,
158            );
159        }
160
161        while let Some(actamb) = neiq.pop_front() {
162            let neis_out = clamp_geom(rng.gen_geom(p_geom_out));
163            let neis_in = clamp_geom(rng.gen_geom(p_geom_in));
164
165            burn_direction(
166                &mut outneis,
167                actamb,
168                neis_out,
169                &mut visited,
170                &mut neiq,
171                &mut edges,
172                &mut inneis,
173                actnode,
174                stamp,
175                BurnSide::Out,
176                &mut rng,
177            );
178            burn_direction(
179                &mut inneis,
180                actamb,
181                neis_in,
182                &mut visited,
183                &mut neiq,
184                &mut edges,
185                &mut outneis,
186                actnode,
187                stamp,
188                BurnSide::In,
189                &mut rng,
190            );
191        }
192    }
193
194    let mut g = Graph::new(n, directed)?;
195    g.add_edges(edges)?;
196    Ok(g)
197}
198
199/// Bound `Geom(p)` draws to the platform `u32::MAX` window so the
200/// "draw >= `no_out`" upstream short-circuit still holds when the
201/// geometric tail returns a very large number.
202fn clamp_geom(x: f64) -> u32 {
203    if !x.is_finite() || x >= f64::from(u32::MAX) {
204        return u32::MAX;
205    }
206    x as u32
207}
208
209#[derive(Clone, Copy)]
210enum BurnSide {
211    Out,
212    In,
213}
214
215/// Burn through `count` neighbours on one side (`side`) of ambassador
216/// `actamb`. Mirrors the upstream Fisher–Yates partial-shuffle path
217/// that picks distinct, still-unvisited targets and falls through to
218/// the "burn them all" branch when `count` exceeds the side's size.
219#[allow(clippy::too_many_arguments)]
220fn burn_direction(
221    burn_side_arr: &mut [Vec<u32>],
222    actamb: u32,
223    count: u32,
224    visited: &mut [u32],
225    neiq: &mut VecDeque<u32>,
226    edges: &mut Vec<(VertexId, VertexId)>,
227    counter_side_arr: &mut [Vec<u32>],
228    actnode: u32,
229    stamp: u32,
230    side: BurnSide,
231    rng: &mut SplitMix64,
232) {
233    let side_len = burn_side_arr[actamb as usize].len();
234    if count as usize >= side_len {
235        // Burn them all — no shuffling required.
236        for i in 0..side_len {
237            let nei = burn_side_arr[actamb as usize][i];
238            try_add_edge_side(
239                visited,
240                neiq,
241                edges,
242                burn_side_arr,
243                counter_side_arr,
244                actnode,
245                nei,
246                stamp,
247                side,
248            );
249        }
250        return;
251    }
252
253    let mut left = side_len;
254    let mut taken: usize = 0;
255    let target = count as usize;
256    while taken < target && left > 0 {
257        let which = rng.gen_index(left);
258        let nei = burn_side_arr[actamb as usize][which];
259        // Swap-to-end so the next draw covers a different slot.
260        burn_side_arr[actamb as usize].swap(which, left - 1);
261        if visited[nei as usize] != stamp {
262            try_add_edge_side(
263                visited,
264                neiq,
265                edges,
266                burn_side_arr,
267                counter_side_arr,
268                actnode,
269                nei,
270                stamp,
271                side,
272            );
273            taken += 1;
274        }
275        left -= 1;
276    }
277}
278
279#[allow(clippy::too_many_arguments)]
280fn try_add_edge_side(
281    visited: &mut [u32],
282    neiq: &mut VecDeque<u32>,
283    edges: &mut Vec<(VertexId, VertexId)>,
284    burn_side_arr: &mut [Vec<u32>],
285    counter_side_arr: &mut [Vec<u32>],
286    actnode: u32,
287    nei: u32,
288    stamp: u32,
289    side: BurnSide,
290) {
291    if visited[nei as usize] == stamp {
292        return;
293    }
294    visited[nei as usize] = stamp;
295    neiq.push_back(nei);
296    edges.push((actnode as VertexId, nei as VertexId));
297    // Mirror upstream's adjacency bookkeeping: the new edge actnode → nei
298    // adds `nei` to actnode's outneis and `actnode` to nei's inneis.
299    // The arrays we pass in switch roles depending on which side we
300    // were burning, but the bookkeeping itself is direction-aware.
301    match side {
302        BurnSide::Out => {
303            // burn_side_arr is outneis; counter_side_arr is inneis.
304            burn_side_arr[actnode as usize].push(nei);
305            counter_side_arr[nei as usize].push(actnode);
306        }
307        BurnSide::In => {
308            // burn_side_arr is inneis; counter_side_arr is outneis.
309            counter_side_arr[actnode as usize].push(nei);
310            burn_side_arr[nei as usize].push(actnode);
311        }
312    }
313}
314
315#[allow(clippy::too_many_arguments)]
316fn try_add_edge(
317    visited: &mut [u32],
318    neiq: &mut VecDeque<u32>,
319    edges: &mut Vec<(VertexId, VertexId)>,
320    outneis: &mut [Vec<u32>],
321    inneis: &mut [Vec<u32>],
322    actnode: u32,
323    nei: u32,
324    stamp: u32,
325) {
326    if visited[nei as usize] == stamp {
327        return;
328    }
329    visited[nei as usize] = stamp;
330    neiq.push_back(nei);
331    edges.push((actnode as VertexId, nei as VertexId));
332    outneis[actnode as usize].push(nei);
333    inneis[nei as usize].push(actnode);
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use std::collections::HashSet;
340
341    fn canonical_edges(g: &Graph) -> HashSet<(VertexId, VertexId)> {
342        let n_edges = u32::try_from(g.ecount()).expect("ecount fits in u32 for tests");
343        (0..n_edges)
344            .map(|eid| {
345                let (a, b) = g.edge(eid).expect("edge id in bounds");
346                if a <= b { (a, b) } else { (b, a) }
347            })
348            .collect()
349    }
350
351    #[test]
352    fn n_zero_returns_empty_graph() {
353        let g = forest_fire_game(0, 0.1, 0.3, 2, true, 1).unwrap();
354        assert_eq!(g.vcount(), 0);
355        assert_eq!(g.ecount(), 0);
356    }
357
358    #[test]
359    fn n_one_returns_singleton() {
360        let g = forest_fire_game(1, 0.1, 0.3, 2, true, 1).unwrap();
361        assert_eq!(g.vcount(), 1);
362        assert_eq!(g.ecount(), 0);
363    }
364
365    #[test]
366    fn ambs_zero_yields_isolated_vertices() {
367        let g = forest_fire_game(50, 0.2, 0.5, 0, true, 1).unwrap();
368        assert_eq!(g.vcount(), 50);
369        assert_eq!(g.ecount(), 0);
370    }
371
372    #[test]
373    fn directed_flag_honoured() {
374        let g = forest_fire_game(20, 0.1, 0.3, 1, true, 42).unwrap();
375        assert!(g.is_directed());
376        let g = forest_fire_game(20, 0.1, 0.3, 1, false, 42).unwrap();
377        assert!(!g.is_directed());
378    }
379
380    #[test]
381    fn at_least_one_edge_per_new_node_when_ambs_positive() {
382        // With p = 0 the burn never spreads beyond ambassadors. From
383        // actnode = k, `ambs` uniform draws from [0, k) yield between
384        // 1 (all duplicates, possible only when k = 1) and
385        // min(ambs, k) distinct edges. For n = 30, ambs = 2 the band
386        // is [29, 1 + 2·28] = [29, 57].
387        let g = forest_fire_game(30, 0.0, 0.0, 2, true, 5).unwrap();
388        let ec = g.ecount();
389        assert!((29..=57).contains(&ec), "edges {ec} outside [29, 57]");
390    }
391
392    #[test]
393    fn determinism_same_seed_same_graph() {
394        let a = forest_fire_game(100, 0.2, 0.5, 2, true, 0xC0DE).unwrap();
395        let b = forest_fire_game(100, 0.2, 0.5, 2, true, 0xC0DE).unwrap();
396        assert_eq!(a.ecount(), b.ecount());
397        assert_eq!(canonical_edges(&a), canonical_edges(&b));
398    }
399
400    #[test]
401    fn distinct_seeds_differ() {
402        let a = forest_fire_game(200, 0.25, 0.4, 2, true, 1).unwrap();
403        let b = forest_fire_game(200, 0.25, 0.4, 2, true, 2).unwrap();
404        assert_ne!(canonical_edges(&a), canonical_edges(&b));
405    }
406
407    #[test]
408    fn no_self_loops() {
409        let g = forest_fire_game(80, 0.3, 0.4, 2, true, 9).unwrap();
410        let n_edges = u32::try_from(g.ecount()).unwrap();
411        for eid in 0..n_edges {
412            let (a, b) = g.edge(eid).unwrap();
413            assert_ne!(a, b);
414        }
415    }
416
417    #[test]
418    fn no_duplicate_edges_per_new_node() {
419        // Within a single `actnode` burn, the visited stamp prevents
420        // re-citing. Across actnodes the new edges always start at the
421        // current `actnode`, so directed (src, dst) pairs are unique
422        // (src = actnode appears exactly once across the whole run).
423        let g = forest_fire_game(80, 0.3, 0.4, 2, true, 9).unwrap();
424        let n_edges = u32::try_from(g.ecount()).unwrap();
425        let mut seen: HashSet<(VertexId, VertexId)> = HashSet::new();
426        for eid in 0..n_edges {
427            let (a, b) = g.edge(eid).unwrap();
428            assert!(seen.insert((a, b)), "duplicate directed edge {a}->{b}");
429        }
430    }
431
432    #[test]
433    fn fw_prob_out_of_range_errors() {
434        assert!(forest_fire_game(10, -0.1, 0.3, 2, true, 1).is_err());
435        assert!(forest_fire_game(10, 1.0, 0.3, 2, true, 1).is_err());
436        assert!(forest_fire_game(10, 1.5, 0.3, 2, true, 1).is_err());
437        assert!(forest_fire_game(10, f64::NAN, 0.3, 2, true, 1).is_err());
438        assert!(forest_fire_game(10, f64::INFINITY, 0.3, 2, true, 1).is_err());
439    }
440
441    #[test]
442    fn bw_factor_combined_out_of_range_errors() {
443        // bw_factor * fw_prob = 0.6 * 0.9 = 0.54 — ok
444        assert!(forest_fire_game(10, 0.9, 0.6, 2, true, 1).is_ok());
445        // bw_factor * fw_prob = 1.2 * 0.9 = 1.08 — invalid
446        assert!(forest_fire_game(10, 0.9, 1.2, 2, true, 1).is_err());
447        // negative bw_factor — invalid
448        assert!(forest_fire_game(10, 0.5, -0.1, 2, true, 1).is_err());
449    }
450
451    #[test]
452    fn ambs_capped_to_existing_nodes() {
453        // ambs = 10, n = 5, p = 0. For actnode = k the 10 draws from
454        // [0, k) yield at most min(10, k) = k distinct edges. With
455        // small k the birthday paradox almost guarantees the cap is
456        // hit, so total edges land near the upper bound
457        // 1+2+3+4 = 10. The lower bound 4 (one distinct per actnode)
458        // is mathematically possible but vanishingly unlikely.
459        let g = forest_fire_game(5, 0.0, 0.0, 10, true, 7).unwrap();
460        let ec = g.ecount();
461        assert!((4..=10).contains(&ec), "edges {ec} outside [4, 10]");
462    }
463
464    #[test]
465    fn growth_monotone_in_p() {
466        // Higher fw_prob → larger expected burn → more edges (in
467        // expectation). We test across several seeds since the
468        // monotonicity only holds in expectation, and accept the
469        // average over a small ensemble.
470        let mut low_total: u64 = 0;
471        let mut high_total: u64 = 0;
472        for seed in 0..8u64 {
473            let lo = forest_fire_game(50, 0.05, 0.5, 2, true, seed).unwrap();
474            let hi = forest_fire_game(50, 0.35, 0.5, 2, true, seed).unwrap();
475            low_total += lo.ecount() as u64;
476            high_total += hi.ecount() as u64;
477        }
478        assert!(high_total > low_total);
479    }
480
481    #[test]
482    fn each_actnode_emits_only_outgoing_edges() {
483        // By construction, every edge has src = some actnode ≥ 1 and
484        // dst < src. In the directed view this means every vertex has
485        // in-degree 0 if no later vertex ever cited it — but more
486        // importantly src > dst always holds for the emitted edges.
487        let g = forest_fire_game(60, 0.3, 0.4, 2, true, 3).unwrap();
488        let n_edges = u32::try_from(g.ecount()).unwrap();
489        for eid in 0..n_edges {
490            let (a, b) = g.edge(eid).unwrap();
491            assert!(a > b, "edge {a}->{b} violates DAG-by-construction");
492        }
493    }
494}