1#![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
73pub 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 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 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 visited[actnode as usize] = stamp;
145
146 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
199fn 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#[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 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 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 match side {
302 BurnSide::Out => {
303 burn_side_arr[actnode as usize].push(nei);
305 counter_side_arr[nei as usize].push(actnode);
306 }
307 BurnSide::In => {
308 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 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 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 assert!(forest_fire_game(10, 0.9, 0.6, 2, true, 1).is_ok());
445 assert!(forest_fire_game(10, 0.9, 1.2, 2, true, 1).is_err());
447 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 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 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 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}