scirs2_graph/generators/temporal.rs
1//! Temporal graph generation models
2//!
3//! This module provides generators for temporal (time-evolving) networks beyond
4//! the basic activity-driven model already in `temporal_graph`. Three models
5//! are implemented:
6//!
7//! * **`temporal_barabasi_albert`** – preferential attachment with timestamps.
8//! Each new node arrives at a specified time, contacts `m` existing nodes
9//! preferentially (higher-degree nodes are preferred), and the resulting
10//! contacts are recorded as temporal edges. Produces scale-free degree
11//! distributions with a well-defined temporal ordering.
12//!
13//! * **`TemporalGraph`** (re-exported from `temporal_graph`) – the core
14//! temporal graph data structure used throughout this module.
15//!
16//! * **`temporal_random_walk`** – time-respecting random walks on a temporal
17//! graph. A walk is *time-respecting* if each successive edge has a
18//! timestamp ≥ the previous edge's timestamp. This is the standard
19//! foundation for temporal node embeddings and reachability analysis.
20//!
21//! # References
22//!
23//! - Barabási, A.-L. & Albert, R. (1999). Emergence of scaling in random
24//! networks. *Science*, 286, 509–512.
25//! - Holme, P. & Saramäki, J. (2012). Temporal networks. *Physics Reports*,
26//! 519(3), 97–125.
27//! - Pan, R. K. & Saramäki, J. (2011). Path lengths, correlations, and
28//! centrality in temporal networks. *Physical Review E*, 84, 016105.
29
30use crate::error::{GraphError, Result};
31use crate::temporal_graph::{TemporalEdge, TemporalGraph};
32use scirs2_core::rand_prelude::IndexedRandom;
33use scirs2_core::random::prelude::*;
34
35// Re-export the core temporal types so users can access everything from this
36// module without digging into `temporal_graph`.
37pub use crate::temporal_graph::{activity_driven_model, activity_driven_model_seeded, burstiness};
38
39// ─────────────────────────────────────────────────────────────────────────────
40// Temporal Barabási–Albert
41// ─────────────────────────────────────────────────────────────────────────────
42
43/// Generate a temporal Barabási–Albert (TBA) network with preferential attachment.
44///
45/// Nodes are added at uniformly spaced timestamps `0, 1/n, 2/n, …, (n-1)/n`.
46/// Each new node contacts `m` existing nodes chosen proportionally to their
47/// current degree (preferential attachment). Each contact is recorded as a
48/// temporal edge with the arrival timestamp of the new node.
49///
50/// The process starts with a complete graph on `m + 1` seed nodes (all with
51/// timestamp 0.0) so that the preferential-attachment step always has viable
52/// targets.
53///
54/// # Arguments
55///
56/// * `n` – total number of nodes (must be > m)
57/// * `m` – number of edges added per new node (must be ≥ 1)
58/// * `rng` – seeded random-number generator
59///
60/// # Returns
61///
62/// A [`TemporalGraph`] with `n` nodes and approximately `n·m` temporal edges.
63///
64/// # Errors
65///
66/// Returns [`GraphError::InvalidGraph`] when parameters are invalid.
67///
68/// # Example
69///
70/// ```rust
71/// use scirs2_graph::generators::temporal::temporal_barabasi_albert;
72/// use scirs2_core::random::prelude::*;
73/// let mut rng = StdRng::seed_from_u64(42);
74/// let tg = temporal_barabasi_albert(50, 2, &mut rng).unwrap();
75/// assert_eq!(tg.node_count(), 50);
76/// ```
77pub fn temporal_barabasi_albert<R: Rng>(n: usize, m: usize, rng: &mut R) -> Result<TemporalGraph> {
78 if n < 2 {
79 return Err(GraphError::InvalidGraph(
80 "temporal_barabasi_albert: n must be ≥ 2".to_string(),
81 ));
82 }
83 if m == 0 {
84 return Err(GraphError::InvalidGraph(
85 "temporal_barabasi_albert: m must be ≥ 1".to_string(),
86 ));
87 }
88 if m >= n {
89 return Err(GraphError::InvalidGraph(format!(
90 "temporal_barabasi_albert: m={m} must be < n={n}"
91 )));
92 }
93
94 let mut tg = TemporalGraph::new(n);
95
96 // Degree tracker for preferential attachment (degree in the snapshot graph)
97 let mut degree = vec![0usize; n];
98
99 // Seed: complete graph on the first `m + 1` nodes at time 0.0
100 let seed_size = m + 1;
101 for u in 0..seed_size {
102 for v in (u + 1)..seed_size {
103 tg.add_edge(TemporalEdge::new(u, v, 0.0));
104 tg.add_edge(TemporalEdge::new(v, u, 0.0));
105 degree[u] += 1;
106 degree[v] += 1;
107 }
108 }
109
110 // Arrival step
111 for v in seed_size..n {
112 let t_arrive = v as f64 / n as f64;
113
114 // Build a weighted target list proportional to degree
115 // (add +1 to each to avoid zero-weight nodes)
116 let candidates: Vec<usize> = (0..v).collect();
117 let weights: Vec<f64> = candidates.iter().map(|&u| (degree[u] + 1) as f64).collect();
118
119 // Sample m distinct nodes
120 let chosen = weighted_sample_without_replacement(&candidates, &weights, m, rng);
121
122 for &u in &chosen {
123 tg.add_edge(TemporalEdge::new(v, u, t_arrive));
124 tg.add_edge(TemporalEdge::new(u, v, t_arrive));
125 degree[u] += 1;
126 degree[v] += 1;
127 }
128 }
129
130 Ok(tg)
131}
132
133/// Weighted sampling without replacement using the Efraimidis–Spirakis reservoir
134/// algorithm (A-Res), adapted for small samples.
135fn weighted_sample_without_replacement<R: Rng>(
136 items: &[usize],
137 weights: &[f64],
138 k: usize,
139 rng: &mut R,
140) -> Vec<usize> {
141 if items.is_empty() || k == 0 {
142 return Vec::new();
143 }
144 let k = k.min(items.len());
145
146 // Compute key = u^(1/w) for each item; take the k items with highest keys.
147 // Use a simple sort-based approach (fine for moderate n).
148 let mut keyed: Vec<(f64, usize)> = items
149 .iter()
150 .zip(weights.iter())
151 .map(|(&item, &w)| {
152 let u: f64 = rng.random::<f64>().max(1e-300);
153 let key = if w > 0.0 { u.powf(1.0 / w) } else { 0.0 };
154 (key, item)
155 })
156 .collect();
157
158 // Partial sort: we only need the top-k by key (descending)
159 keyed.sort_unstable_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
160 keyed[..k].iter().map(|&(_, item)| item).collect()
161}
162
163// ─────────────────────────────────────────────────────────────────────────────
164// Temporal random walk
165// ─────────────────────────────────────────────────────────────────────────────
166
167/// A single time-respecting random walk on a temporal graph.
168///
169/// Each step of the walk moves to a random neighbour via an edge whose
170/// timestamp is ≥ the timestamp of the edge used at the previous step.
171/// This ensures the walk is *causal* (it only traverses forward in time).
172#[derive(Debug, Clone)]
173pub struct TemporalWalk {
174 /// Ordered sequence of (node, edge_timestamp) pairs visited during the walk.
175 /// The first entry is the starting node with timestamp `-∞` (represented as
176 /// `f64::NEG_INFINITY`).
177 pub steps: Vec<(usize, f64)>,
178}
179
180impl TemporalWalk {
181 /// Return the sequence of node IDs visited (including the starting node).
182 pub fn nodes(&self) -> Vec<usize> {
183 self.steps.iter().map(|&(n, _)| n).collect()
184 }
185
186 /// Return the length of the walk (number of edges traversed).
187 pub fn len(&self) -> usize {
188 self.steps.len().saturating_sub(1)
189 }
190
191 /// Return `true` if the walk consists of only the starting node (no edges).
192 pub fn is_empty(&self) -> bool {
193 self.steps.len() <= 1
194 }
195}
196
197/// Perform `num_walks` time-respecting random walks starting from `source`.
198///
199/// A walk is *time-respecting*: each successive edge must have a timestamp ≥
200/// the timestamp of the previously traversed edge. The walk terminates when:
201/// * `max_length` edges have been traversed, **or**
202/// * no time-respecting edges lead away from the current node.
203///
204/// If `start_time` is `None`, the walk may use any edge (effectively starting
205/// at `t = -∞`).
206///
207/// # Arguments
208///
209/// * `tg` – the temporal graph to walk on
210/// * `source` – starting node (0-based index; must be < `tg.node_count()`)
211/// * `max_length` – maximum number of steps per walk
212/// * `num_walks` – number of independent walks to generate
213/// * `start_time` – earliest timestamp the first edge may have (`None` = no constraint)
214/// * `rng` – seeded random-number generator
215///
216/// # Returns
217///
218/// A `Vec<TemporalWalk>` of length `num_walks`.
219///
220/// # Errors
221///
222/// Returns [`GraphError::InvalidGraph`] when `source ≥ n`.
223///
224/// # Example
225///
226/// ```rust
227/// use scirs2_graph::generators::temporal::{temporal_random_walk, temporal_barabasi_albert};
228/// use scirs2_core::random::prelude::*;
229/// let mut rng = StdRng::seed_from_u64(1);
230/// let tg = temporal_barabasi_albert(20, 2, &mut rng).unwrap();
231/// let walks = temporal_random_walk(&tg, 0, 5, 3, None, &mut rng).unwrap();
232/// assert_eq!(walks.len(), 3);
233/// ```
234pub fn temporal_random_walk<R: Rng>(
235 tg: &TemporalGraph,
236 source: usize,
237 max_length: usize,
238 num_walks: usize,
239 start_time: Option<f64>,
240 rng: &mut R,
241) -> Result<Vec<TemporalWalk>> {
242 if source >= tg.node_count() {
243 return Err(GraphError::InvalidGraph(format!(
244 "temporal_random_walk: source={source} ≥ n={}",
245 tg.node_count()
246 )));
247 }
248 if max_length == 0 {
249 return Err(GraphError::InvalidGraph(
250 "temporal_random_walk: max_length must be ≥ 1".to_string(),
251 ));
252 }
253
254 let mut walks = Vec::with_capacity(num_walks);
255
256 // Pre-sort edges and build per-node outgoing edge lists sorted by timestamp.
257 // TemporalGraph already keeps edges sorted when we call sort() or use
258 // sorted_edges().
259 let sorted_edges = tg.sorted_edges_cloned();
260
261 // Build adjacency: node → [(neighbour, timestamp)] sorted by timestamp
262 let n = tg.node_count();
263 let mut adj: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n];
264 for edge in &sorted_edges {
265 // The temporal graph edges are directed by convention; treat them
266 // as undirected for walk purposes (add both directions).
267 adj[edge.source].push((edge.target, edge.timestamp));
268 // Avoid duplicate reverse edges for undirected treatment
269 adj[edge.target].push((edge.source, edge.timestamp));
270 }
271 // Sort per-node adjacency by timestamp
272 for nbrs in adj.iter_mut() {
273 nbrs.sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
274 }
275
276 let t0 = start_time.unwrap_or(f64::NEG_INFINITY);
277
278 for _ in 0..num_walks {
279 let mut walk_steps = vec![(source, t0)];
280 let mut current_node = source;
281 let mut current_time = t0;
282
283 for _ in 0..max_length {
284 // Collect time-respecting candidates: edges with timestamp ≥ current_time
285 let candidates: Vec<(usize, f64)> = adj[current_node]
286 .iter()
287 .filter(|&&(_, t)| t >= current_time)
288 .copied()
289 .collect();
290
291 if candidates.is_empty() {
292 break;
293 }
294
295 // Uniformly choose a random candidate
296 let &(next_node, next_time) = candidates.choose(rng).expect("candidates is non-empty");
297
298 walk_steps.push((next_node, next_time));
299 current_node = next_node;
300 current_time = next_time;
301 }
302
303 walks.push(TemporalWalk { steps: walk_steps });
304 }
305
306 Ok(walks)
307}
308
309// ─────────────────────────────────────────────────────────────────────────────
310// Tests
311// ─────────────────────────────────────────────────────────────────────────────
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 // ── temporal_barabasi_albert ──────────────────────────────────────────
318
319 #[test]
320 fn test_tba_basic() {
321 let mut rng = StdRng::seed_from_u64(42);
322 let tg = temporal_barabasi_albert(20, 2, &mut rng)
323 .expect("temporal_barabasi_albert should succeed");
324 assert_eq!(tg.node_count(), 20);
325 // With m=2 we add at least 2 edges per new node → at least (n - m - 1) * m edges
326 assert!(tg.n_edges() > 0);
327 }
328
329 #[test]
330 fn test_tba_scale_free_degree() {
331 let mut rng = StdRng::seed_from_u64(7);
332 let tg = temporal_barabasi_albert(100, 3, &mut rng)
333 .expect("temporal_barabasi_albert should succeed");
334 assert_eq!(tg.node_count(), 100);
335 // Nodes should have at least 1 temporal edge each (except possibly isolated ones)
336 assert!(tg.n_edges() > 0);
337 }
338
339 #[test]
340 fn test_tba_invalid_params() {
341 let mut rng = StdRng::seed_from_u64(0);
342 // n < 2
343 assert!(temporal_barabasi_albert(1, 1, &mut rng).is_err());
344 // m = 0
345 assert!(temporal_barabasi_albert(10, 0, &mut rng).is_err());
346 // m >= n
347 assert!(temporal_barabasi_albert(5, 5, &mut rng).is_err());
348 }
349
350 #[test]
351 fn test_tba_timestamps_increasing() {
352 let mut rng = StdRng::seed_from_u64(13);
353 let tg = temporal_barabasi_albert(30, 2, &mut rng)
354 .expect("temporal_barabasi_albert should succeed");
355 // All timestamps should be ≥ 0
356 for edge in tg.sorted_edges_cloned() {
357 assert!(
358 edge.timestamp >= 0.0,
359 "All timestamps should be non-negative"
360 );
361 }
362 }
363
364 // ── temporal_random_walk ─────────────────────────────────────────────
365
366 #[test]
367 fn test_random_walk_basic() {
368 let mut rng = StdRng::seed_from_u64(1);
369 let tg = temporal_barabasi_albert(20, 2, &mut rng)
370 .expect("temporal_barabasi_albert should succeed");
371 let walks = temporal_random_walk(&tg, 0, 5, 3, None, &mut rng)
372 .expect("temporal_random_walk should succeed");
373 assert_eq!(walks.len(), 3);
374 for walk in &walks {
375 // Walk length ≤ max_length
376 assert!(walk.len() <= 5);
377 // First step is the source node
378 assert_eq!(walk.steps[0].0, 0);
379 }
380 }
381
382 #[test]
383 fn test_random_walk_time_respecting() {
384 let mut rng = StdRng::seed_from_u64(2);
385 let tg = temporal_barabasi_albert(30, 2, &mut rng)
386 .expect("temporal_barabasi_albert should succeed");
387 let walks = temporal_random_walk(&tg, 0, 10, 5, None, &mut rng)
388 .expect("temporal_random_walk should succeed");
389
390 for walk in &walks {
391 // Timestamps should be non-decreasing
392 let timestamps: Vec<f64> = walk.steps.iter().map(|&(_, t)| t).collect();
393 for window in timestamps.windows(2) {
394 assert!(
395 window[1] >= window[0],
396 "Walk timestamps must be non-decreasing: {:?}",
397 timestamps
398 );
399 }
400 }
401 }
402
403 #[test]
404 fn test_random_walk_with_start_time() {
405 let mut rng = StdRng::seed_from_u64(3);
406 let tg = temporal_barabasi_albert(20, 2, &mut rng)
407 .expect("temporal_barabasi_albert should succeed");
408 let start_t = 0.5;
409 let walks = temporal_random_walk(&tg, 0, 5, 2, Some(start_t), &mut rng)
410 .expect("temporal_random_walk should succeed");
411
412 for walk in &walks {
413 for &(_, t) in &walk.steps[1..] {
414 // All edge timestamps in the walk should be ≥ start_t
415 assert!(
416 t >= start_t,
417 "Edge timestamps should be ≥ start_time={start_t}, got {t}"
418 );
419 }
420 }
421 }
422
423 #[test]
424 fn test_random_walk_invalid_source() {
425 let mut rng = StdRng::seed_from_u64(0);
426 let tg = temporal_barabasi_albert(10, 2, &mut rng)
427 .expect("temporal_barabasi_albert should succeed");
428 assert!(temporal_random_walk(&tg, 100, 5, 1, None, &mut rng).is_err());
429 }
430
431 #[test]
432 fn test_random_walk_zero_max_length() {
433 let mut rng = StdRng::seed_from_u64(0);
434 let tg = temporal_barabasi_albert(10, 2, &mut rng)
435 .expect("temporal_barabasi_albert should succeed");
436 assert!(temporal_random_walk(&tg, 0, 0, 1, None, &mut rng).is_err());
437 }
438
439 #[test]
440 fn test_temporal_walk_is_empty() {
441 // A walk with only the start node
442 let walk = TemporalWalk {
443 steps: vec![(0, f64::NEG_INFINITY)],
444 };
445 assert!(walk.is_empty());
446 assert_eq!(walk.len(), 0);
447 assert_eq!(walk.nodes(), vec![0]);
448 }
449
450 #[test]
451 fn test_temporal_walk_nodes() {
452 let walk = TemporalWalk {
453 steps: vec![(0, 0.0), (1, 1.0), (2, 2.0)],
454 };
455 assert_eq!(walk.nodes(), vec![0, 1, 2]);
456 assert_eq!(walk.len(), 2);
457 assert!(!walk.is_empty());
458 }
459}