Skip to main content

scirs2_graph/temporal/
graph.rs

1//! Temporal graph data structures (stream model, f64 timestamps)
2//!
3//! This module provides `TemporalEdge` and `TemporalGraph` types for the
4//! stream-of-interactions model where each edge carries a real-valued timestamp.
5//! These types are designed for temporal network analysis algorithms such as
6//! centrality, motif counting, and dynamic community detection.
7//!
8//! The `TemporalGraph` here is distinct from `crate::temporal::TemporalGraph`
9//! (which uses generic typed nodes with time intervals) and from
10//! `crate::temporal_graph::TemporalGraph` (the original stream model).
11//! This module provides an ergonomic API focused on algorithm composability.
12
13use crate::base::Graph;
14use std::collections::{HashMap, HashSet};
15
16// ─────────────────────────────────────────────────────────────────────────────
17// TemporalEdge
18// ─────────────────────────────────────────────────────────────────────────────
19
20/// A single directed temporal edge `(source, target, timestamp, weight)`.
21///
22/// Edges are directed by convention; callers wanting undirected semantics should
23/// insert both `(u, v)` and `(v, u)` versions.
24#[derive(Debug, Clone, PartialEq)]
25pub struct TemporalEdge {
26    /// Source node index (0-based, must be `< TemporalGraph::nodes`)
27    pub source: usize,
28    /// Target node index (0-based, must be `< TemporalGraph::nodes`)
29    pub target: usize,
30    /// Continuous-time timestamp of the interaction
31    pub timestamp: f64,
32    /// Edge weight (defaults to `1.0`)
33    pub weight: f64,
34}
35
36impl TemporalEdge {
37    /// Create a unit-weight temporal edge.
38    pub fn new(source: usize, target: usize, timestamp: f64) -> Self {
39        TemporalEdge {
40            source,
41            target,
42            timestamp,
43            weight: 1.0,
44        }
45    }
46
47    /// Create a weighted temporal edge.
48    pub fn with_weight(source: usize, target: usize, timestamp: f64, weight: f64) -> Self {
49        TemporalEdge {
50            source,
51            target,
52            timestamp,
53            weight,
54        }
55    }
56}
57
58// ─────────────────────────────────────────────────────────────────────────────
59// TemporalGraph
60// ─────────────────────────────────────────────────────────────────────────────
61
62/// Temporal graph in the stream-of-interactions model.
63///
64/// Stores a sorted sequence of `TemporalEdge` contacts; nodes are identified
65/// by consecutive `usize` indices `0..nodes`.
66///
67/// ## Example
68/// ```
69/// use scirs2_graph::temporal::TemporalGraph;
70/// use scirs2_graph::temporal::TemporalEdge;
71///
72/// let mut tg = TemporalGraph::new(4);
73/// tg.add_edge(TemporalEdge::new(0, 1, 1.0));
74/// tg.add_edge(TemporalEdge::new(1, 2, 2.0));
75/// tg.add_edge(TemporalEdge::new(2, 3, 3.0));
76///
77/// // Static snapshot of interactions in [0.5, 2.5)
78/// let snap = tg.snapshot(0.5, 2.5);
79/// assert!(snap.edge_count() >= 1);
80/// ```
81#[derive(Debug, Clone)]
82pub struct TemporalGraph {
83    /// Total number of nodes (fixed at construction; node indices are `0..nodes`)
84    pub nodes: usize,
85    /// Edge stream, sorted by timestamp
86    pub edges: Vec<TemporalEdge>,
87    /// Internal: whether `edges` is currently sorted
88    sorted: bool,
89}
90
91impl TemporalGraph {
92    /// Create an empty temporal graph with `n_nodes` nodes.
93    pub fn new(n_nodes: usize) -> Self {
94        TemporalGraph {
95            nodes: n_nodes,
96            edges: Vec::new(),
97            sorted: true,
98        }
99    }
100
101    /// Add a temporal edge.  The internal list is lazily re-sorted when queried.
102    pub fn add_edge(&mut self, edge: TemporalEdge) {
103        if let Some(last) = self.edges.last() {
104            if edge.timestamp < last.timestamp {
105                self.sorted = false;
106            }
107        }
108        self.edges.push(edge);
109    }
110
111    /// Ensure `edges` is sorted by timestamp (stable sort, O(n log n) worst case).
112    pub(crate) fn ensure_sorted(&mut self) {
113        if !self.sorted {
114            self.edges.sort_by(|a, b| {
115                a.timestamp
116                    .partial_cmp(&b.timestamp)
117                    .unwrap_or(std::cmp::Ordering::Equal)
118            });
119            self.sorted = true;
120        }
121    }
122
123    /// Return all edges in sorted order.
124    pub fn time_ordered_edges(&mut self) -> &[TemporalEdge] {
125        self.ensure_sorted();
126        &self.edges
127    }
128
129    /// Borrow edges without sorting (useful if you know they are already sorted).
130    pub fn edges_slice(&self) -> &[TemporalEdge] {
131        &self.edges
132    }
133
134    /// Return edges in the half-open window `[start, end)`.
135    pub fn edges_in_window(&mut self, start: f64, end: f64) -> &[TemporalEdge] {
136        self.ensure_sorted();
137        let lo = self.edges.partition_point(|e| e.timestamp < start);
138        let hi = self.edges.partition_point(|e| e.timestamp < end);
139        &self.edges[lo..hi]
140    }
141
142    // ────────────────────────────────────────────────────────────────────────
143    // snapshot
144    // ────────────────────────────────────────────────────────────────────────
145
146    /// Build a static undirected snapshot for the window `[start, end)`.
147    ///
148    /// Repeated contacts between the same pair of nodes accumulate weights.
149    /// The returned `Graph<usize, f64>` contains only nodes that appear in
150    /// at least one edge within the window.
151    pub fn snapshot(&mut self, start: f64, end: f64) -> Graph<usize, f64> {
152        let window: Vec<TemporalEdge> = self.edges_in_window(start, end).to_vec();
153
154        let mut edge_weights: HashMap<(usize, usize), f64> = HashMap::new();
155        let mut active: HashSet<usize> = HashSet::new();
156
157        for e in &window {
158            active.insert(e.source);
159            active.insert(e.target);
160            let key = (e.source.min(e.target), e.source.max(e.target));
161            *edge_weights.entry(key).or_insert(0.0) += e.weight;
162        }
163
164        let mut graph: Graph<usize, f64> = Graph::new();
165        for &n in &active {
166            graph.add_node(n);
167        }
168        for ((u, v), w) in edge_weights {
169            // Ignore errors — nodes are already added above
170            let _ = graph.add_edge(u, v, w);
171        }
172        graph
173    }
174
175    // ────────────────────────────────────────────────────────────────────────
176    // aggregate_graph
177    // ────────────────────────────────────────────────────────────────────────
178
179    /// Collapse all temporal contacts into a static undirected weighted graph
180    /// by summing contact weights over all time.
181    pub fn aggregate_graph(&mut self) -> Graph<usize, f64> {
182        if self.edges.is_empty() {
183            let mut g: Graph<usize, f64> = Graph::new();
184            for i in 0..self.nodes {
185                g.add_node(i);
186            }
187            return g;
188        }
189
190        self.ensure_sorted();
191        let t_start = self.edges.first().map(|e| e.timestamp).unwrap_or(0.0);
192        let t_end = self.edges.last().map(|e| e.timestamp + 1.0).unwrap_or(1.0);
193        self.snapshot(t_start, t_end)
194    }
195
196    // ────────────────────────────────────────────────────────────────────────
197    // temporal_neighbors
198    // ────────────────────────────────────────────────────────────────────────
199
200    /// Return all neighbors of `node` contacted in `[t_start, t_end)`.
201    ///
202    /// Returns `(neighbor_id, earliest_contact_time)` pairs (undirected semantics).
203    pub fn temporal_neighbors(
204        &mut self,
205        node: usize,
206        t_start: f64,
207        t_end: f64,
208    ) -> Vec<(usize, f64)> {
209        let window: Vec<TemporalEdge> = self.edges_in_window(t_start, t_end).to_vec();
210
211        let mut first_contact: HashMap<usize, f64> = HashMap::new();
212        for e in &window {
213            if e.source == node {
214                first_contact
215                    .entry(e.target)
216                    .and_modify(|t| *t = t.min(e.timestamp))
217                    .or_insert(e.timestamp);
218            } else if e.target == node {
219                first_contact
220                    .entry(e.source)
221                    .and_modify(|t| *t = t.min(e.timestamp))
222                    .or_insert(e.timestamp);
223            }
224        }
225
226        first_contact.into_iter().collect()
227    }
228
229    // ────────────────────────────────────────────────────────────────────────
230    // foremost_path  (Dijkstra on event times)
231    // ────────────────────────────────────────────────────────────────────────
232
233    /// Find a time-respecting foremost (earliest-arrival) path from `source`
234    /// to `target` using only contacts in `[t_start, t_end)`.
235    ///
236    /// Returns `None` when no such path exists.
237    pub fn foremost_path(
238        &mut self,
239        source: usize,
240        target: usize,
241        t_start: f64,
242        t_end: f64,
243    ) -> Option<Vec<usize>> {
244        if source >= self.nodes || target >= self.nodes {
245            return None;
246        }
247        if source == target {
248            return Some(vec![source]);
249        }
250
251        self.ensure_sorted();
252
253        let mut arrival = vec![f64::INFINITY; self.nodes];
254        arrival[source] = t_start;
255        let mut pred: Vec<Option<usize>> = vec![None; self.nodes];
256
257        // Min-heap keyed on (arrival_time, node)
258        use std::cmp::Reverse;
259        use std::collections::BinaryHeap;
260
261        #[derive(PartialEq)]
262        struct State(ordered_float::OrderedFloat<f64>, usize);
263        impl Eq for State {}
264        impl PartialOrd for State {
265            fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
266                Some(self.cmp(other))
267            }
268        }
269        impl Ord for State {
270            fn cmp(&self, other: &Self) -> std::cmp::Ordering {
271                // We want a min-heap, so we compare (reversed) arrival, then node
272                Reverse(self.0)
273                    .cmp(&Reverse(other.0))
274                    .then(self.1.cmp(&other.1))
275            }
276        }
277
278        let mut heap = BinaryHeap::new();
279        heap.push(State(ordered_float::OrderedFloat(t_start), source));
280
281        while let Some(State(arr_of, node)) = heap.pop() {
282            let arr = arr_of.0;
283            if arr > arrival[node] {
284                continue; // stale
285            }
286            if node == target {
287                let mut path = Vec::new();
288                let mut cur = target;
289                loop {
290                    path.push(cur);
291                    match pred[cur] {
292                        None => break,
293                        Some(p) => cur = p,
294                    }
295                }
296                path.reverse();
297                return Some(path);
298            }
299
300            // Scan edges at or after current arrival
301            let lo = self.edges.partition_point(|e| e.timestamp < arr);
302            for e in &self.edges[lo..] {
303                if e.timestamp >= t_end {
304                    break;
305                }
306                let nbr = if e.source == node {
307                    e.target
308                } else if e.target == node {
309                    e.source
310                } else {
311                    continue;
312                };
313                if e.timestamp < arrival[nbr] {
314                    arrival[nbr] = e.timestamp;
315                    pred[nbr] = Some(node);
316                    heap.push(State(ordered_float::OrderedFloat(e.timestamp), nbr));
317                }
318            }
319        }
320
321        None
322    }
323
324    /// Number of temporal contacts stored.
325    pub fn n_edges(&self) -> usize {
326        self.edges.len()
327    }
328}
329
330// ─────────────────────────────────────────────────────────────────────────────
331// Tests
332// ─────────────────────────────────────────────────────────────────────────────
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    fn make_chain() -> TemporalGraph {
339        let mut tg = TemporalGraph::new(4);
340        tg.add_edge(TemporalEdge::new(0, 1, 1.0));
341        tg.add_edge(TemporalEdge::new(1, 2, 2.0));
342        tg.add_edge(TemporalEdge::new(2, 3, 3.0));
343        tg
344    }
345
346    #[test]
347    fn test_snapshot_basic() {
348        let mut tg = make_chain();
349        let snap = tg.snapshot(0.5, 2.5);
350        // edges at t=1 (0-1) and t=2 (1-2) are in the window
351        assert_eq!(snap.edge_count(), 2);
352    }
353
354    #[test]
355    fn test_time_ordered_edges_sorted() {
356        let mut tg = TemporalGraph::new(3);
357        tg.add_edge(TemporalEdge::new(0, 1, 5.0));
358        tg.add_edge(TemporalEdge::new(1, 2, 2.0));
359        tg.add_edge(TemporalEdge::new(0, 2, 8.0));
360
361        let edges = tg.time_ordered_edges();
362        let ts: Vec<f64> = edges.iter().map(|e| e.timestamp).collect();
363        assert!(ts.windows(2).all(|w| w[0] <= w[1]));
364    }
365
366    #[test]
367    fn test_foremost_path() {
368        let mut tg = make_chain();
369        let path = tg.foremost_path(0, 3, 0.0, 10.0);
370        assert!(path.is_some());
371        let p = path.expect("path should exist");
372        assert_eq!(p.first(), Some(&0));
373        assert_eq!(p.last(), Some(&3));
374    }
375
376    #[test]
377    fn test_foremost_path_no_time_travel() {
378        let mut tg = make_chain();
379        // Only window [0, 2.5) — edge to node 3 at t=3 not visible
380        let path = tg.foremost_path(0, 3, 0.0, 2.5);
381        assert!(path.is_none());
382    }
383
384    #[test]
385    fn test_aggregate_graph() {
386        let mut tg = make_chain();
387        let agg = tg.aggregate_graph();
388        assert_eq!(agg.node_count(), 4);
389        assert_eq!(agg.edge_count(), 3);
390    }
391
392    #[test]
393    fn test_temporal_neighbors() {
394        let mut tg = make_chain();
395        let nbrs = tg.temporal_neighbors(1, 0.0, 10.0);
396        let nbr_ids: Vec<usize> = nbrs.iter().map(|(n, _)| *n).collect();
397        assert!(nbr_ids.contains(&0));
398        assert!(nbr_ids.contains(&2));
399    }
400
401    #[test]
402    fn test_edge_with_weight() {
403        let e = TemporalEdge::with_weight(0, 1, 5.0, 2.5);
404        assert_eq!(e.weight, 2.5);
405    }
406}