Skip to main content

scirs2_graph/dynamic/
link_streams.rs

1//! Link streams: continuous-time temporal graph model (Latapy et al. 2018).
2//!
3//! A *link stream* L = (T, V, E) consists of:
4//! - a time interval T = [α, ω],
5//! - a set of nodes V,
6//! - a set of temporal edges E ⊆ T × V × V, each with an associated duration.
7
8use std::collections::HashSet;
9
10/// A temporal edge in a link stream: active during `[start, end]`.
11#[derive(Debug, Clone)]
12pub struct TemporalEdge {
13    /// Source node.
14    pub src: usize,
15    /// Destination node.
16    pub dst: usize,
17    /// Activation start time.
18    pub start: f64,
19    /// Activation end time.
20    pub end: f64,
21}
22
23impl TemporalEdge {
24    /// Construct a new temporal edge.
25    pub fn new(src: usize, dst: usize, start: f64, end: f64) -> Self {
26        Self {
27            src,
28            dst,
29            start,
30            end,
31        }
32    }
33
34    /// Duration of this contact.
35    pub fn duration(&self) -> f64 {
36        self.end - self.start
37    }
38
39    /// Whether the edge is active at time `t`.
40    pub fn is_active_at(&self, t: f64) -> bool {
41        t >= self.start && t <= self.end
42    }
43}
44
45/// A continuous-time temporal network represented as a link stream.
46pub struct LinkStream {
47    /// All temporal edges in the stream.
48    pub edges: Vec<TemporalEdge>,
49    /// All node identifiers present in the stream.
50    pub nodes: Vec<usize>,
51    /// Start of the observation window.
52    pub time_start: f64,
53    /// End of the observation window.
54    pub time_end: f64,
55}
56
57impl LinkStream {
58    /// Create an empty link stream over the interval `[time_start, time_end]`.
59    pub fn new(time_start: f64, time_end: f64) -> Self {
60        Self {
61            edges: Vec::new(),
62            nodes: Vec::new(),
63            time_start,
64            time_end,
65        }
66    }
67
68    /// Add a temporal edge; both endpoints are registered as nodes if absent.
69    pub fn add_edge(&mut self, src: usize, dst: usize, start: f64, end: f64) {
70        for &n in &[src, dst] {
71            if !self.nodes.contains(&n) {
72                self.nodes.push(n);
73            }
74        }
75        self.edges.push(TemporalEdge::new(src, dst, start, end));
76    }
77
78    /// Instantaneous degree of node `u` at time `t`.
79    pub fn degree_at(&self, u: usize, t: f64) -> usize {
80        self.edges
81            .iter()
82            .filter(|e| (e.src == u || e.dst == u) && e.is_active_at(t))
83            .count()
84    }
85
86    /// Time-averaged degree of node `u`, estimated by uniform sampling.
87    ///
88    /// `n_samples` controls accuracy; higher values reduce discretisation error.
89    pub fn average_degree(&self, u: usize, n_samples: usize) -> f64 {
90        if n_samples == 0 {
91            return 0.0;
92        }
93        let dt = (self.time_end - self.time_start) / n_samples as f64;
94        let sum: f64 = (0..n_samples)
95            .map(|i| {
96                let t = self.time_start + (i as f64 + 0.5) * dt;
97                self.degree_at(u, t) as f64
98            })
99            .sum();
100        sum / n_samples as f64
101    }
102
103    /// Total contact time between nodes `u` and `v`.
104    pub fn contact_duration(&self, u: usize, v: usize) -> f64 {
105        self.edges
106            .iter()
107            .filter(|e| (e.src == u && e.dst == v) || (e.src == v && e.dst == u))
108            .map(|e| e.duration())
109            .sum()
110    }
111
112    /// Vector of gap durations between successive contacts of `u` and `v`.
113    ///
114    /// Contacts are sorted by start time; the gap is the time between the end
115    /// of one contact and the start of the next.  Negative gaps (overlapping
116    /// contacts) are excluded.
117    pub fn inter_contact_times(&self, u: usize, v: usize) -> Vec<f64> {
118        let mut contacts: Vec<(f64, f64)> = self
119            .edges
120            .iter()
121            .filter(|e| (e.src == u && e.dst == v) || (e.src == v && e.dst == u))
122            .map(|e| (e.start, e.end))
123            .collect();
124        contacts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
125
126        contacts
127            .windows(2)
128            .map(|w| w[1].0 - w[0].1)
129            .filter(|&gap| gap > 0.0)
130            .collect()
131    }
132
133    /// Local temporal clustering coefficient of node `u` at time `t`.
134    ///
135    /// Defined as the fraction of pairs among `u`'s neighbours that are
136    /// themselves connected at time `t`.
137    pub fn clustering_coefficient_at(&self, u: usize, t: f64) -> f64 {
138        let neighbors: Vec<usize> = self
139            .edges
140            .iter()
141            .filter(|e| e.is_active_at(t) && (e.src == u || e.dst == u))
142            .map(|e| if e.src == u { e.dst } else { e.src })
143            .collect::<HashSet<_>>()
144            .into_iter()
145            .collect();
146
147        let k = neighbors.len();
148        if k < 2 {
149            return 0.0;
150        }
151
152        let mut triangle_count = 0usize;
153        for i in 0..k {
154            for j in (i + 1)..k {
155                let nb1 = neighbors[i];
156                let nb2 = neighbors[j];
157                let connected = self.edges.iter().any(|e| {
158                    e.is_active_at(t)
159                        && ((e.src == nb1 && e.dst == nb2) || (e.src == nb2 && e.dst == nb1))
160                });
161                if connected {
162                    triangle_count += 1;
163                }
164            }
165        }
166
167        // Maximum possible pairs among k neighbours.
168        let max_pairs = k * (k - 1) / 2;
169        triangle_count as f64 / max_pairs as f64
170    }
171
172    /// Number of distinct nodes.
173    pub fn n_nodes(&self) -> usize {
174        self.nodes.len()
175    }
176
177    /// Number of temporal edges.
178    pub fn n_edges(&self) -> usize {
179        self.edges.len()
180    }
181
182    /// Sum of all edge durations.
183    pub fn total_interaction_time(&self) -> f64 {
184        self.edges.iter().map(|e| e.duration()).sum()
185    }
186
187    /// Stream density: fraction of (node-pair × time) space that is active.
188    ///
189    /// Defined as `total_interaction_time / (|V|*(|V|-1)/2 * T)` for undirected streams.
190    pub fn stream_density(&self) -> f64 {
191        let n = self.nodes.len();
192        if n < 2 {
193            return 0.0;
194        }
195        let duration = self.time_end - self.time_start;
196        if duration <= 0.0 {
197            return 0.0;
198        }
199        let max_pairs = n * (n - 1) / 2;
200        self.total_interaction_time() / (max_pairs as f64 * duration)
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    fn build_stream() -> LinkStream {
209        let mut ls = LinkStream::new(0.0, 10.0);
210        ls.add_edge(0, 1, 0.0, 3.0);
211        ls.add_edge(0, 2, 1.0, 4.0);
212        ls.add_edge(1, 2, 2.0, 5.0);
213        ls
214    }
215
216    #[test]
217    fn test_contact_duration() {
218        let ls = build_stream();
219        assert!((ls.contact_duration(0, 1) - 3.0).abs() < 1e-9);
220        assert!((ls.contact_duration(0, 2) - 3.0).abs() < 1e-9);
221    }
222
223    #[test]
224    fn test_degree_at() {
225        let ls = build_stream();
226        // At t=2.5, edges 0-1, 0-2, 1-2 are all active.
227        assert_eq!(
228            ls.degree_at(0, 2.5),
229            2,
230            "node 0 should have degree 2 at t=2.5"
231        );
232        assert_eq!(ls.degree_at(1, 2.5), 2);
233    }
234
235    #[test]
236    fn test_inter_contact_times() {
237        let mut ls = LinkStream::new(0.0, 20.0);
238        ls.add_edge(0, 1, 0.0, 2.0);
239        ls.add_edge(0, 1, 5.0, 7.0);
240        ls.add_edge(0, 1, 12.0, 14.0);
241        let ict = ls.inter_contact_times(0, 1);
242        assert_eq!(ict.len(), 2, "ict={ict:?}");
243        assert!((ict[0] - 3.0).abs() < 1e-9, "ict[0]={}", ict[0]);
244        assert!((ict[1] - 5.0).abs() < 1e-9, "ict[1]={}", ict[1]);
245    }
246
247    #[test]
248    fn test_clustering_coefficient() {
249        let ls = build_stream();
250        // At t=2.5 node 0 has neighbours {1,2} and they are connected → cc=1.0
251        let cc = ls.clustering_coefficient_at(0, 2.5);
252        assert!((cc - 1.0).abs() < 1e-9, "cc={cc}");
253    }
254
255    #[test]
256    fn test_total_interaction_time() {
257        let ls = build_stream();
258        // 3 + 3 + 3 = 9
259        assert!((ls.total_interaction_time() - 9.0).abs() < 1e-9);
260    }
261}