Skip to main content

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}