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}