Skip to main content

scirs2_graph/
temporal_graph.rs

1//! Temporal and Dynamic Graphs
2//!
3//! This module provides data structures and algorithms for temporal (dynamic) graphs,
4//! where edges carry continuous-time timestamps rather than discrete time intervals.
5//! It implements the stream-of-interactions model commonly used in the analysis of
6//! real-world contact networks, communication networks, and social interaction data.
7//!
8//! # Key Concepts
9//!
10//! - **Temporal edge**: a directed or undirected contact `(u, v, t, w)` at time `t`
11//! - **Time-respecting path**: a sequence of edges whose timestamps are non-decreasing
12//! - **Temporal betweenness**: how often a node lies on optimal time-respecting paths
13//! - **Burstiness**: statistical irregularity of inter-event times (Goh–Barabási 2008)
14//! - **Activity-driven model**: synthetic generative model (Perra et al. 2012)
15//!
16//! # References
17//!
18//! - Holme & Saramäki, "Temporal networks", Physics Reports 519(3), 2012.
19//! - Goh & Barabási, "Burstiness and memory in complex systems", EPL 81(4), 2008.
20//! - Perra et al., "Activity driven modeling of time-varying networks", Sci. Rep. 2012.
21
22use crate::base::{Graph, IndexType};
23use crate::error::{GraphError, Result};
24use scirs2_core::random::{Rng, RngExt, SeedableRng};
25use std::collections::{BinaryHeap, HashMap, HashSet};
26
27// ---------------------------------------------------------------------------
28// Core types
29// ---------------------------------------------------------------------------
30
31/// A single temporal edge with a continuous-time timestamp.
32///
33/// Edges are directed by convention (source → target); callers who need undirected
34/// semantics should add both directions.
35#[derive(Debug, Clone, PartialEq)]
36pub struct TemporalEdge {
37    /// Source node index (0-based)
38    pub source: usize,
39    /// Target node index (0-based)
40    pub target: usize,
41    /// Time of the interaction (any real-valued unit: seconds, days, …)
42    pub timestamp: f64,
43    /// Optional edge weight (e.g. call duration, message volume)
44    pub weight: Option<f64>,
45}
46
47impl TemporalEdge {
48    /// Create an unweighted temporal edge.
49    pub fn new(source: usize, target: usize, timestamp: f64) -> Self {
50        TemporalEdge {
51            source,
52            target,
53            timestamp,
54            weight: None,
55        }
56    }
57
58    /// Create a weighted temporal edge.
59    pub fn with_weight(source: usize, target: usize, timestamp: f64, weight: f64) -> Self {
60        TemporalEdge {
61            source,
62            target,
63            timestamp,
64            weight: Some(weight),
65        }
66    }
67}
68
69// ---------------------------------------------------------------------------
70// TemporalGraph struct
71// ---------------------------------------------------------------------------
72
73/// A temporal (dynamic) graph stored as a sorted stream of timed edge contacts.
74///
75/// Nodes are identified by consecutive `usize` indices `0..n_nodes`.
76/// Edges are kept sorted by timestamp to enable efficient windowed queries.
77///
78/// ## Example
79/// ```
80/// use scirs2_graph::temporal_graph::{TemporalGraph, TemporalEdge};
81///
82/// let mut tg = TemporalGraph::new(4);
83/// tg.add_edge(TemporalEdge::new(0, 1, 1.0));
84/// tg.add_edge(TemporalEdge::new(1, 2, 2.0));
85/// tg.add_edge(TemporalEdge::new(2, 3, 3.0));
86///
87/// let snap = tg.snapshot(0.5, 2.5);
88/// assert_eq!(snap.node_count(), 3); // nodes 0,1,2 appear in edges
89/// ```
90#[derive(Debug, Clone)]
91pub struct TemporalGraph {
92    /// Total number of nodes (fixed at construction time)
93    n_nodes: usize,
94    /// Edge stream sorted by timestamp (maintained automatically)
95    edges: Vec<TemporalEdge>,
96    /// Whether the edges vector is currently sorted
97    sorted: bool,
98}
99
100impl TemporalGraph {
101    /// Create an empty temporal graph with a fixed node count.
102    pub fn new(n_nodes: usize) -> Self {
103        TemporalGraph {
104            n_nodes,
105            edges: Vec::new(),
106            sorted: true,
107        }
108    }
109
110    /// Number of nodes (constant after construction).
111    pub fn n_nodes(&self) -> usize {
112        self.n_nodes
113    }
114
115    /// Number of temporal edge contacts stored.
116    pub fn n_edges(&self) -> usize {
117        self.edges.len()
118    }
119
120    /// Alias for [] — returns the number of nodes in this temporal graph.
121    ///
122    /// Provided for compatibility with code expecting a  method.
123    pub fn node_count(&self) -> usize {
124        self.n_nodes
125    }
126
127    /// Return a sorted clone of all temporal edges.
128    ///
129    /// Unlike [](Self::edges) this method does not require a mutable
130    /// reference; instead it returns an owned  sorted by
131    /// timestamp.  This is slightly less efficient than the mutable version
132    /// (one extra allocation + possible sort), but works with `&self` borrows.
133    pub fn sorted_edges_cloned(&self) -> Vec<TemporalEdge> {
134        let mut v = self.edges.clone();
135        if !self.sorted {
136            v.sort_by(|a, b| {
137                a.timestamp
138                    .partial_cmp(&b.timestamp)
139                    .unwrap_or(std::cmp::Ordering::Equal)
140            });
141        }
142        v
143    }
144
145    /// Add a temporal edge.  Marks the edge list as unsorted when the new
146    /// timestamp is earlier than the last stored timestamp.
147    pub fn add_edge(&mut self, edge: TemporalEdge) {
148        if let Some(last) = self.edges.last() {
149            if edge.timestamp < last.timestamp {
150                self.sorted = false;
151            }
152        }
153        self.edges.push(edge);
154    }
155
156    /// Ensure the internal edge list is sorted by timestamp (stable sort).
157    fn ensure_sorted(&mut self) {
158        if !self.sorted {
159            self.edges.sort_by(|a, b| {
160                a.timestamp
161                    .partial_cmp(&b.timestamp)
162                    .unwrap_or(std::cmp::Ordering::Equal)
163            });
164            self.sorted = true;
165        }
166    }
167
168    /// Return a read-only slice of all temporal edges (in sorted order).
169    pub fn edges(&mut self) -> &[TemporalEdge] {
170        self.ensure_sorted();
171        &self.edges
172    }
173
174    /// Return an iterator over edges in the time window `[t_start, t_end)`.
175    pub fn edges_in_window(&mut self, t_start: f64, t_end: f64) -> &[TemporalEdge] {
176        self.ensure_sorted();
177        // Binary search bounds
178        let lo = self.edges.partition_point(|e| e.timestamp < t_start);
179        let hi = self.edges.partition_point(|e| e.timestamp < t_end);
180        &self.edges[lo..hi]
181    }
182
183    // -----------------------------------------------------------------------
184    // snapshot
185    // -----------------------------------------------------------------------
186
187    /// Build a static (undirected, weighted) snapshot of the temporal graph for
188    /// all contacts that fall in the half-open window `[t_start, t_end)`.
189    ///
190    /// Repeated contacts between the same pair of nodes accumulate their weights
191    /// (or count as 1.0 each when no weight is present).
192    pub fn snapshot(&mut self, t_start: f64, t_end: f64) -> Graph<usize, f64> {
193        let window = self.edges_in_window(t_start, t_end);
194
195        // Accumulate edge weights
196        let mut edge_weights: HashMap<(usize, usize), f64> = HashMap::new();
197        let mut active_nodes: HashSet<usize> = HashSet::new();
198
199        for e in window {
200            active_nodes.insert(e.source);
201            active_nodes.insert(e.target);
202            let key = (e.source.min(e.target), e.source.max(e.target));
203            let w = e.weight.unwrap_or(1.0);
204            *edge_weights.entry(key).or_insert(0.0) += w;
205        }
206
207        let mut graph: Graph<usize, f64> = Graph::new();
208        for &n in &active_nodes {
209            graph.add_node(n);
210        }
211        for ((u, v), w) in edge_weights {
212            // ignore errors (node not found would be a logic bug; nodes added above)
213            let _ = graph.add_edge(u, v, w);
214        }
215        graph
216    }
217
218    // -----------------------------------------------------------------------
219    // temporal_neighbors
220    // -----------------------------------------------------------------------
221
222    /// Return all neighbours of `node` reachable in the time window
223    /// `[t_start, t_end)`, together with the earliest contact timestamp.
224    ///
225    /// Both directions of each edge are considered (undirected semantics).
226    pub fn temporal_neighbors(
227        &mut self,
228        node: usize,
229        t_start: f64,
230        t_end: f64,
231    ) -> Vec<(usize, f64)> {
232        let window = self.edges_in_window(t_start, t_end);
233        let mut first_contact: HashMap<usize, f64> = HashMap::new();
234
235        for e in window {
236            if e.source == node {
237                first_contact
238                    .entry(e.target)
239                    .and_modify(|t| *t = t.min(e.timestamp))
240                    .or_insert(e.timestamp);
241            } else if e.target == node {
242                first_contact
243                    .entry(e.source)
244                    .and_modify(|t| *t = t.min(e.timestamp))
245                    .or_insert(e.timestamp);
246            }
247        }
248
249        first_contact.into_iter().collect()
250    }
251
252    // -----------------------------------------------------------------------
253    // temporal_path
254    // -----------------------------------------------------------------------
255
256    /// Find a time-respecting path from `source` to `target` using only edges
257    /// with timestamps in `[t_start, t_end)`.
258    ///
259    /// Uses a Dijkstra-like priority queue keyed on the earliest-arrival time
260    /// at each node, guaranteeing the fastest-arrival (foremost) path.
261    ///
262    /// Returns `None` when no such path exists.
263    pub fn temporal_path(
264        &mut self,
265        source: usize,
266        target: usize,
267        t_start: f64,
268        t_end: f64,
269    ) -> Option<Vec<usize>> {
270        if source >= self.n_nodes || target >= self.n_nodes {
271            return None;
272        }
273        if source == target {
274            return Some(vec![source]);
275        }
276
277        self.ensure_sorted();
278        let edges = &self.edges;
279
280        // arrival_time[v] = earliest time we can arrive at v
281        let mut arrival: Vec<f64> = vec![f64::INFINITY; self.n_nodes];
282        arrival[source] = t_start;
283
284        // predecessor for path reconstruction
285        let mut pred: Vec<Option<usize>> = vec![None; self.n_nodes];
286
287        // Min-heap: (arrival_time, node) — we use ordered floats via bit conversion
288        // BinaryHeap is a max-heap, so we negate
289        #[derive(PartialEq)]
290        struct State {
291            neg_arrival: ordered_float::OrderedFloat<f64>,
292            node: usize,
293        }
294        impl Eq for State {}
295        impl PartialOrd for State {
296            fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
297                Some(self.cmp(other))
298            }
299        }
300        impl Ord for State {
301            fn cmp(&self, other: &Self) -> std::cmp::Ordering {
302                self.neg_arrival
303                    .cmp(&other.neg_arrival)
304                    .then(self.node.cmp(&other.node))
305            }
306        }
307
308        let mut heap = BinaryHeap::new();
309        heap.push(State {
310            neg_arrival: ordered_float::OrderedFloat(-t_start),
311            node: source,
312        });
313
314        while let Some(State { neg_arrival, node }) = heap.pop() {
315            let arr = -neg_arrival.0;
316            if arr > arrival[node] {
317                // stale entry
318                continue;
319            }
320            if node == target {
321                // Reconstruct path
322                let mut path = Vec::new();
323                let mut cur = target;
324                loop {
325                    path.push(cur);
326                    match pred[cur] {
327                        None => break,
328                        Some(p) => cur = p,
329                    }
330                }
331                path.reverse();
332                return Some(path);
333            }
334
335            // Scan only edges at or after our current arrival time
336            let lo = edges.partition_point(|e| e.timestamp < arr);
337            for e in &edges[lo..] {
338                if e.timestamp >= t_end {
339                    break;
340                }
341                let neighbour = if e.source == node {
342                    e.target
343                } else if e.target == node {
344                    e.source
345                } else {
346                    continue;
347                };
348                if e.timestamp < arrival[neighbour] {
349                    arrival[neighbour] = e.timestamp;
350                    pred[neighbour] = Some(node);
351                    heap.push(State {
352                        neg_arrival: ordered_float::OrderedFloat(-e.timestamp),
353                        node: neighbour,
354                    });
355                }
356            }
357        }
358
359        None
360    }
361
362    // -----------------------------------------------------------------------
363    // temporal_betweenness
364    // -----------------------------------------------------------------------
365
366    /// Compute temporal betweenness centrality for all nodes.
367    ///
368    /// For each ordered pair `(s, t)` we find the set of foremost
369    /// (earliest-arrival) paths using the method above and give each
370    /// intermediate node `1 / |paths|` credit.
371    ///
372    /// Returns a vector of length `n_nodes`.
373    pub fn temporal_betweenness(&mut self, t_start: f64, t_end: f64) -> Vec<f64> {
374        let n = self.n_nodes;
375        let mut bet = vec![0.0f64; n];
376
377        for s in 0..n {
378            for t in 0..n {
379                if s == t {
380                    continue;
381                }
382                if let Some(path) = self.temporal_path(s, t, t_start, t_end) {
383                    // intermediate nodes only
384                    let len = path.len();
385                    if len > 2 {
386                        let credit = 1.0 / (len - 2) as f64;
387                        for &v in &path[1..len - 1] {
388                            bet[v] += credit;
389                        }
390                    }
391                }
392            }
393        }
394
395        // Normalise by max possible (n-1)(n-2)
396        let norm = (n.saturating_sub(1) * n.saturating_sub(2)) as f64;
397        if norm > 0.0 {
398            for b in &mut bet {
399                *b /= norm;
400            }
401        }
402        bet
403    }
404
405    // -----------------------------------------------------------------------
406    // aggregate_graph
407    // -----------------------------------------------------------------------
408
409    /// Collapse the temporal graph to a static undirected weighted graph by
410    /// summing contact weights over all time.
411    pub fn aggregate_graph(&mut self) -> Graph<usize, f64> {
412        let t_start = self
413            .edges
414            .iter()
415            .map(|e| e.timestamp)
416            .fold(f64::INFINITY, f64::min);
417        let t_end = self
418            .edges
419            .iter()
420            .map(|e| e.timestamp)
421            .fold(f64::NEG_INFINITY, f64::max);
422
423        if t_start.is_infinite() {
424            // empty graph
425            let mut g: Graph<usize, f64> = Graph::new();
426            for i in 0..self.n_nodes {
427                g.add_node(i);
428            }
429            return g;
430        }
431
432        // Include the last timestamp — use open upper bound slightly above it
433        self.snapshot(t_start, t_end + 1.0)
434    }
435}
436
437// ---------------------------------------------------------------------------
438// burstiness
439// ---------------------------------------------------------------------------
440
441/// Compute the Goh–Barabási **burstiness** coefficient for a sequence of
442/// event times belonging to a single node.
443///
444/// Given inter-event times `τ_i = t_{i+1} − t_i`, the burstiness is:
445///
446/// ```text
447/// B = (σ − μ) / (σ + μ)
448/// ```
449///
450/// where `μ` and `σ` are the mean and standard deviation of the inter-event
451/// times.  `B ∈ (-1, 1]`:
452/// - `B = 0` → Poisson (regular random)
453/// - `B > 0` → bursty
454/// - `B < 0` → regular / periodic
455///
456/// Returns `0.0` if fewer than 2 events are provided.
457///
458/// # Reference
459/// Goh & Barabási, "Burstiness and memory in complex systems", EPL 81(4) 48002, 2008.
460pub fn burstiness(node_events: &[f64]) -> f64 {
461    if node_events.len() < 2 {
462        return 0.0;
463    }
464
465    // Compute inter-event times
466    let mut sorted = node_events.to_vec();
467    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
468
469    let iet: Vec<f64> = sorted.windows(2).map(|w| w[1] - w[0]).collect();
470    let n = iet.len() as f64;
471
472    let mu = iet.iter().sum::<f64>() / n;
473    if mu == 0.0 {
474        return 0.0;
475    }
476
477    let variance = iet.iter().map(|x| (x - mu).powi(2)).sum::<f64>() / n;
478    let sigma = variance.sqrt();
479
480    (sigma - mu) / (sigma + mu)
481}
482
483// ---------------------------------------------------------------------------
484// activity_driven_model
485// ---------------------------------------------------------------------------
486
487/// Generate a synthetic temporal graph using the **Activity-Driven Model**
488/// (Perra et al. 2012).
489///
490/// At each discrete step `t = 0, 1, …, n_steps - 1`:
491/// 1. Each node `i` becomes *active* with probability `activity_rates[i]`.
492/// 2. Each active node creates one undirected contact with a uniformly
493///    chosen partner (self-loops excluded).
494///
495/// The resulting graph is stored as a sorted stream of `TemporalEdge` contacts
496/// with `timestamp = t as f64`.
497///
498/// # Arguments
499/// * `n_nodes`         – number of nodes
500/// * `n_steps`         – number of discrete time steps
501/// * `activity_rates`  – activity probability for each node (values in `[0,1]`)
502/// * `rng`             – seeded random number generator
503///
504/// # Errors
505/// Returns `GraphError::InvalidGraph` if `activity_rates.len() != n_nodes` or
506/// any activity rate is outside `[0, 1]`.
507pub fn activity_driven_model<R: Rng>(
508    n_nodes: usize,
509    n_steps: usize,
510    activity_rates: &[f64],
511    rng: &mut R,
512) -> Result<TemporalGraph> {
513    if activity_rates.len() != n_nodes {
514        return Err(GraphError::InvalidGraph(format!(
515            "activity_rates length {} != n_nodes {}",
516            activity_rates.len(),
517            n_nodes
518        )));
519    }
520    for (i, &a) in activity_rates.iter().enumerate() {
521        if !(0.0..=1.0).contains(&a) {
522            return Err(GraphError::InvalidGraph(format!(
523                "activity_rates[{i}] = {a} is outside [0, 1]"
524            )));
525        }
526    }
527    if n_nodes < 2 {
528        return Err(GraphError::InvalidGraph(
529            "need at least 2 nodes for activity-driven model".to_string(),
530        ));
531    }
532
533    let mut tg = TemporalGraph::new(n_nodes);
534
535    for step in 0..n_steps {
536        let t = step as f64;
537        for i in 0..n_nodes {
538            if rng.random::<f64>() < activity_rates[i] {
539                // Choose a partner uniformly at random (excluding self)
540                let offset: usize = rng.random_range(0..(n_nodes - 1));
541                let j = if offset < i { offset } else { offset + 1 };
542                tg.add_edge(TemporalEdge::new(i, j, t));
543            }
544        }
545    }
546
547    tg.ensure_sorted();
548    Ok(tg)
549}
550
551// ---------------------------------------------------------------------------
552// Convenience constructor — seeded activity-driven model
553// ---------------------------------------------------------------------------
554
555/// Convenience wrapper: run [`activity_driven_model`] with a seeded
556/// `ChaCha20` RNG.
557pub fn activity_driven_model_seeded(
558    n_nodes: usize,
559    n_steps: usize,
560    activity_rates: &[f64],
561    seed: u64,
562) -> Result<TemporalGraph> {
563    use scirs2_core::random::ChaCha20Rng;
564    let mut rng = ChaCha20Rng::seed_from_u64(seed);
565    activity_driven_model(n_nodes, n_steps, activity_rates, &mut rng)
566}
567
568// ---------------------------------------------------------------------------
569// Tests
570// ---------------------------------------------------------------------------
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575
576    fn make_chain() -> TemporalGraph {
577        // 0 -> 1 at t=1, 1 -> 2 at t=2, 2 -> 3 at t=3
578        let mut tg = TemporalGraph::new(4);
579        tg.add_edge(TemporalEdge::new(0, 1, 1.0));
580        tg.add_edge(TemporalEdge::new(1, 2, 2.0));
581        tg.add_edge(TemporalEdge::new(2, 3, 3.0));
582        tg
583    }
584
585    #[test]
586    fn test_add_and_sort() {
587        let mut tg = TemporalGraph::new(3);
588        tg.add_edge(TemporalEdge::new(0, 1, 5.0));
589        tg.add_edge(TemporalEdge::new(1, 2, 2.0)); // out-of-order
590        tg.add_edge(TemporalEdge::new(0, 2, 8.0));
591
592        let edges = tg.edges();
593        assert_eq!(edges.len(), 3);
594        // must be sorted
595        let timestamps: Vec<f64> = edges.iter().map(|e| e.timestamp).collect();
596        assert!(timestamps.windows(2).all(|w| w[0] <= w[1]));
597    }
598
599    #[test]
600    fn test_snapshot() {
601        let mut tg = make_chain();
602        let snap = tg.snapshot(0.0, 2.5);
603        // edges at t=1 (0-1) and t=2 (1-2) are included
604        assert_eq!(snap.edge_count(), 2);
605    }
606
607    #[test]
608    fn test_temporal_neighbors() {
609        let mut tg = make_chain();
610        let nbrs = tg.temporal_neighbors(1, 0.0, 10.0);
611        let nbr_ids: Vec<usize> = nbrs.iter().map(|(n, _)| *n).collect();
612        assert!(nbr_ids.contains(&0));
613        assert!(nbr_ids.contains(&2));
614    }
615
616    #[test]
617    fn test_temporal_path_found() {
618        let mut tg = make_chain();
619        let path = tg.temporal_path(0, 3, 0.0, 10.0);
620        assert!(path.is_some());
621        let p = path.expect("path should exist");
622        assert_eq!(p.first(), Some(&0));
623        assert_eq!(p.last(), Some(&3));
624    }
625
626    #[test]
627    fn test_temporal_path_no_backwards() {
628        // Only allow a narrow window that cannot see t=3
629        let mut tg = make_chain();
630        let path = tg.temporal_path(0, 3, 0.0, 2.5);
631        // Can't reach node 3 — its edge appears at t=3
632        assert!(path.is_none());
633    }
634
635    #[test]
636    fn test_temporal_path_same_source_target() {
637        let mut tg = make_chain();
638        let path = tg.temporal_path(2, 2, 0.0, 10.0);
639        assert_eq!(path, Some(vec![2]));
640    }
641
642    #[test]
643    fn test_temporal_betweenness_chain() {
644        let mut tg = make_chain();
645        let bet = tg.temporal_betweenness(0.0, 10.0);
646        // On a chain 0-1-2-3, nodes 1 and 2 should have non-zero betweenness
647        assert_eq!(bet.len(), 4);
648        // node 0 and node 3 are endpoints, should have 0 betweenness
649        assert_eq!(bet[0], 0.0);
650        assert_eq!(bet[3], 0.0);
651    }
652
653    #[test]
654    fn test_burstiness_regular() {
655        // Regular intervals → sigma=0, B = (0-mu)/(0+mu) = -1 (perfectly periodic)
656        let events: Vec<f64> = (0..10).map(|i| i as f64).collect();
657        let b = burstiness(&events);
658        assert!(
659            (b - (-1.0)).abs() < 1e-9,
660            "perfectly regular events should have B=-1 (periodic), got {b}"
661        );
662    }
663
664    #[test]
665    fn test_burstiness_few_events() {
666        assert_eq!(burstiness(&[]), 0.0);
667        assert_eq!(burstiness(&[1.0]), 0.0);
668    }
669
670    #[test]
671    fn test_burstiness_bursty() {
672        // A truly bursty sequence: very small intervals followed by a large gap
673        // IETs: [0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 0.001, 1000.0]
674        // sigma >> mu => B positive (bursty)
675        let mut events: Vec<f64> = (0..10).map(|i| i as f64 * 0.001).collect();
676        events.push(1000.0);
677        let b = burstiness(&events);
678        // Should be positive (bursty)
679        assert!(b > 0.0, "bursty sequence should have B>0, got {b}");
680    }
681
682    #[test]
683    fn test_activity_driven_model() {
684        let rates = vec![0.5; 5];
685        let tg = activity_driven_model_seeded(5, 20, &rates, 42).expect("model generation failed");
686        assert_eq!(tg.n_nodes(), 5);
687        // With 5 nodes and 20 steps at activity 0.5, expect some edges
688        assert!(tg.n_edges() > 0);
689    }
690
691    #[test]
692    fn test_activity_driven_model_validation() {
693        // Wrong length
694        let err = activity_driven_model_seeded(5, 10, &[0.5; 3], 0);
695        assert!(err.is_err());
696
697        // Rate out of range
698        let err = activity_driven_model_seeded(3, 10, &[0.5, 1.5, 0.3], 0);
699        assert!(err.is_err());
700
701        // Too few nodes
702        let err = activity_driven_model_seeded(1, 10, &[0.5], 0);
703        assert!(err.is_err());
704    }
705
706    #[test]
707    fn test_aggregate_graph() {
708        let mut tg = make_chain();
709        let agg = tg.aggregate_graph();
710        // All 4 nodes should be present (0,1,2,3)
711        assert_eq!(agg.node_count(), 4);
712        assert_eq!(agg.edge_count(), 3);
713    }
714
715    #[test]
716    fn test_weighted_edge() {
717        let e = TemporalEdge::with_weight(0, 1, 5.0, 2.5);
718        assert_eq!(e.weight, Some(2.5));
719        assert_eq!(e.timestamp, 5.0);
720    }
721
722    #[test]
723    fn test_edges_in_window() {
724        let mut tg = TemporalGraph::new(3);
725        for t in [1.0, 2.0, 3.0, 4.0, 5.0] {
726            tg.add_edge(TemporalEdge::new(0, 1, t));
727        }
728        let window = tg.edges_in_window(2.0, 4.0);
729        assert_eq!(window.len(), 2); // t=2 and t=3
730    }
731}