Skip to main content

geographdb_core/algorithms/
causal.rs

1use crate::algorithms::four_d::GraphNode4D;
2use std::collections::{HashMap, HashSet, VecDeque};
3
4/// Alexandrov interval I[src,dst] = {w : src →* w →* dst} in the temporal causal order.
5#[derive(Debug, Clone)]
6pub struct CausalInterval {
7    pub src: u64,
8    pub dst: u64,
9    /// |I[src,dst]| including both endpoints
10    pub volume: usize,
11    /// Length of the longest directed path from src to dst (causal proper time)
12    pub proper_time: u32,
13}
14
15/// Aggregate statistics for the causal set defined by the temporal subgraph.
16#[derive(Debug, Clone)]
17pub struct CausalStats {
18    pub n_nodes: usize,
19    pub n_related_pairs: usize,
20    /// Myrheim-Meyer dimension estimate (log-log regression slope of volume vs proper_time).
21    /// NaN if fewer than 2 pairs with proper_time ≥ 2 exist.
22    pub mm_dimension: f32,
23    pub mean_volume: f32,
24    pub mean_proper_time: f32,
25}
26
27/// Forward and reverse adjacency maps using only temporal edges (edge.end_ts > edge.begin_ts).
28fn build_causal_adj(nodes: &[GraphNode4D]) -> (HashMap<u64, Vec<u64>>, HashMap<u64, Vec<u64>>) {
29    let mut fwd: HashMap<u64, Vec<u64>> = HashMap::with_capacity(nodes.len());
30    let mut rev: HashMap<u64, Vec<u64>> = HashMap::with_capacity(nodes.len());
31    for node in nodes {
32        fwd.entry(node.id).or_default();
33        rev.entry(node.id).or_default();
34        for edge in &node.successors {
35            if edge.end_ts > edge.begin_ts {
36                fwd.entry(node.id).or_default().push(edge.dst);
37                rev.entry(edge.dst).or_default().push(node.id);
38            }
39        }
40    }
41    (fwd, rev)
42}
43
44/// BFS reachability from `start` following `adj`. Returns empty if start is not in adj.
45fn reachable(adj: &HashMap<u64, Vec<u64>>, start: u64) -> HashSet<u64> {
46    if !adj.contains_key(&start) {
47        return HashSet::new();
48    }
49    let mut visited = HashSet::new();
50    let mut queue = VecDeque::from([start]);
51    while let Some(cur) = queue.pop_front() {
52        if !visited.insert(cur) {
53            continue;
54        }
55        if let Some(neighbors) = adj.get(&cur) {
56            for &nb in neighbors {
57                if !visited.contains(&nb) {
58                    queue.push_back(nb);
59                }
60            }
61        }
62    }
63    visited
64}
65
66/// Longest directed path from `src` to `dst` within `interval` using DAG DP.
67/// Topological order is derived from node.begin_ts (strictly increasing along temporal edges).
68fn longest_path(
69    node_map: &HashMap<u64, &GraphNode4D>,
70    fwd_adj: &HashMap<u64, Vec<u64>>,
71    interval: &HashSet<u64>,
72    src: u64,
73    dst: u64,
74) -> u32 {
75    if src == dst {
76        return 0;
77    }
78    let mut topo: Vec<u64> = interval.iter().cloned().collect();
79    topo.sort_by_key(|&id| node_map.get(&id).map(|n| n.begin_ts).unwrap_or(0));
80
81    let mut dist: HashMap<u64, u32> = HashMap::new();
82    dist.insert(src, 0);
83
84    for &u in &topo {
85        let d_u = match dist.get(&u) {
86            Some(&d) => d,
87            None => continue,
88        };
89        if let Some(neighbors) = fwd_adj.get(&u) {
90            for &v in neighbors {
91                if interval.contains(&v) {
92                    let e = dist.entry(v).or_insert(0);
93                    if d_u + 1 > *e {
94                        *e = d_u + 1;
95                    }
96                }
97            }
98        }
99    }
100
101    dist.get(&dst).copied().unwrap_or(0)
102}
103
104/// Computes all Alexandrov intervals in the temporal causal order.
105///
106/// Only temporal edges (edge.end_ts > edge.begin_ts) define causal precedence.
107/// For each pair (src, dst) where dst is reachable from src, the interval
108/// I[src,dst] = forward_reach(src) ∩ backward_reach(dst), inclusive of endpoints.
109pub fn causal_intervals(nodes: &[GraphNode4D]) -> Vec<CausalInterval> {
110    let node_map: HashMap<u64, &GraphNode4D> = nodes.iter().map(|n| (n.id, n)).collect();
111    let (fwd_adj, rev_adj) = build_causal_adj(nodes);
112
113    let fwd_reach: HashMap<u64, HashSet<u64>> = nodes
114        .iter()
115        .map(|n| (n.id, reachable(&fwd_adj, n.id)))
116        .collect();
117    let bwd_reach: HashMap<u64, HashSet<u64>> = nodes
118        .iter()
119        .map(|n| (n.id, reachable(&rev_adj, n.id)))
120        .collect();
121
122    let mut result = Vec::new();
123    for node in nodes {
124        let u = node.id;
125        let Some(fwd_u) = fwd_reach.get(&u) else {
126            continue;
127        };
128        for &v in fwd_u {
129            if v == u {
130                continue;
131            }
132            let Some(bwd_v) = bwd_reach.get(&v) else {
133                continue;
134            };
135            let interval: HashSet<u64> = fwd_u.intersection(bwd_v).cloned().collect();
136            let volume = interval.len();
137            let tau = longest_path(&node_map, &fwd_adj, &interval, u, v);
138            result.push(CausalInterval {
139                src: u,
140                dst: v,
141                volume,
142                proper_time: tau,
143            });
144        }
145    }
146    result
147}
148
149/// Aggregate causal-set statistics including the Myrheim-Meyer dimension estimate.
150pub fn causal_stats(nodes: &[GraphNode4D]) -> CausalStats {
151    let intervals = causal_intervals(nodes);
152    let n_nodes = nodes.len();
153    let n_related_pairs = intervals.len();
154
155    if intervals.is_empty() {
156        return CausalStats {
157            n_nodes,
158            n_related_pairs: 0,
159            mm_dimension: f32::NAN,
160            mean_volume: 0.0,
161            mean_proper_time: 0.0,
162        };
163    }
164
165    let mean_volume =
166        intervals.iter().map(|i| i.volume as f32).sum::<f32>() / n_related_pairs as f32;
167    let mean_proper_time =
168        intervals.iter().map(|i| i.proper_time as f32).sum::<f32>() / n_related_pairs as f32;
169
170    // Log-log regression slope: ln(volume) ~ mm_dim * ln(proper_time)
171    // Only pairs with proper_time ≥ 2 contribute (avoid log(1) clustering noise).
172    let long_pairs: Vec<(f32, f32)> = intervals
173        .iter()
174        .filter(|i| i.proper_time >= 2)
175        .map(|i| ((i.proper_time as f32).ln(), (i.volume as f32).ln()))
176        .collect();
177
178    let mm_dimension = if long_pairs.len() < 2 {
179        f32::NAN
180    } else {
181        let n = long_pairs.len() as f32;
182        let mean_x = long_pairs.iter().map(|(x, _)| x).sum::<f32>() / n;
183        let mean_y = long_pairs.iter().map(|(_, y)| y).sum::<f32>() / n;
184        let cov = long_pairs
185            .iter()
186            .map(|(x, y)| (x - mean_x) * (y - mean_y))
187            .sum::<f32>();
188        let var_x = long_pairs
189            .iter()
190            .map(|(x, _)| (x - mean_x).powi(2))
191            .sum::<f32>();
192        if var_x < 1e-10 {
193            f32::NAN
194        } else {
195            cov / var_x
196        }
197    };
198
199    CausalStats {
200        n_nodes,
201        n_related_pairs,
202        mm_dimension,
203        mean_volume,
204        mean_proper_time,
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::algorithms::four_d::{GraphNode4D, GraphProperties, TemporalEdge};
212    use crate::algorithms::tnet4d::build_tnet4d;
213
214    fn temporal_edge(dst: u64, begin_ts: u64, end_ts: u64) -> TemporalEdge {
215        TemporalEdge {
216            dst,
217            weight: 1.0,
218            begin_ts,
219            end_ts,
220        }
221    }
222
223    fn spatial_edge(dst: u64, ts: u64) -> TemporalEdge {
224        TemporalEdge {
225            dst,
226            weight: 1.0,
227            begin_ts: ts,
228            end_ts: ts,
229        }
230    }
231
232    fn node(id: u64, t: u64, succs: Vec<TemporalEdge>) -> GraphNode4D {
233        GraphNode4D {
234            id,
235            x: id as f32,
236            y: 0.0,
237            z: 0.0,
238            begin_ts: t,
239            end_ts: t + 1,
240            properties: GraphProperties::default(),
241            successors: succs,
242        }
243    }
244
245    fn make_chain(n: usize) -> Vec<GraphNode4D> {
246        (0..n as u64)
247            .map(|i| {
248                let succs = if i + 1 < n as u64 {
249                    vec![temporal_edge(i + 1, i, i + 1)]
250                } else {
251                    vec![]
252                };
253                node(i, i, succs)
254            })
255            .collect()
256    }
257
258    // ── reachable ─────────────────────────────────────────────────────────────
259
260    #[test]
261    fn test_reachable_single_chain() {
262        // 0→1→2→3: reachable from 0 via temporal edges = {0,1,2,3}
263        let nodes = make_chain(4);
264        let (fwd, _) = build_causal_adj(&nodes);
265        let reach = reachable(&fwd, 0);
266        assert!(reach.contains(&1), "0 must reach 1");
267        assert!(reach.contains(&2), "0 must reach 2");
268        assert!(reach.contains(&3), "0 must reach 3");
269        assert!(!reach.contains(&4), "4 does not exist");
270    }
271
272    #[test]
273    fn test_reachable_missing_start_is_empty() {
274        // Node not in adjacency map → empty reachable set
275        let nodes: Vec<GraphNode4D> = vec![];
276        let (fwd, _) = build_causal_adj(&nodes);
277        let reach = reachable(&fwd, 42);
278        assert!(reach.is_empty(), "missing start → empty reachable set");
279    }
280
281    // ── causal_intervals ─────────────────────────────────────────────────────
282
283    #[test]
284    fn test_interval_chain_inclusive() {
285        // 0→1→2→3: I[0,3] = {0,1,2,3}, volume=4, proper_time=3
286        let nodes = make_chain(4);
287        let intervals = causal_intervals(&nodes);
288        let e = intervals
289            .iter()
290            .find(|i| i.src == 0 && i.dst == 3)
291            .expect("interval (0,3) must exist");
292        assert_eq!(e.volume, 4, "I[0,3] volume must be 4");
293        assert_eq!(e.proper_time, 3, "proper_time(0,3) must be 3");
294    }
295
296    #[test]
297    fn test_interval_direct_edge() {
298        // 0→1: I[0,1] = {0,1}, volume=2, proper_time=1
299        let nodes = make_chain(2);
300        let intervals = causal_intervals(&nodes);
301        assert_eq!(intervals.len(), 1);
302        assert_eq!(intervals[0].volume, 2);
303        assert_eq!(intervals[0].proper_time, 1);
304    }
305
306    #[test]
307    fn test_interval_diamond() {
308        // 0→1, 0→2, 1→3, 2→3: I[0,3] = {0,1,2,3}, volume=4, proper_time=2
309        let nodes = vec![
310            node(0, 0, vec![temporal_edge(1, 0, 1), temporal_edge(2, 0, 1)]),
311            node(1, 1, vec![temporal_edge(3, 1, 2)]),
312            node(2, 1, vec![temporal_edge(3, 1, 2)]),
313            node(3, 2, vec![]),
314        ];
315        let intervals = causal_intervals(&nodes);
316        let e = intervals
317            .iter()
318            .find(|i| i.src == 0 && i.dst == 3)
319            .expect("interval (0,3) must exist");
320        assert_eq!(e.volume, 4, "I[0,3] volume must be 4");
321        assert_eq!(e.proper_time, 2, "proper_time(0,3) = 2 hops");
322    }
323
324    #[test]
325    fn test_spatial_edges_ignored() {
326        // Two nodes connected only by spatial edges: no causal intervals
327        let nodes = vec![
328            node(0, 0, vec![spatial_edge(1, 0)]),
329            node(1, 0, vec![spatial_edge(0, 0)]),
330        ];
331        let intervals = causal_intervals(&nodes);
332        assert!(
333            intervals.is_empty(),
334            "spatial-only graph must produce no causal intervals"
335        );
336    }
337
338    #[test]
339    fn test_causal_intervals_count_chain() {
340        // Chain 0→1→2→3→4: C(5,2) = 10 causal pairs
341        let nodes = make_chain(5);
342        let intervals = causal_intervals(&nodes);
343        assert_eq!(
344            intervals.len(),
345            10,
346            "5-node chain must yield 10 causal intervals"
347        );
348    }
349
350    // ── causal_stats ──────────────────────────────────────────────────────────
351
352    #[test]
353    fn test_mm_dimension_chain_approx_one() {
354        // A pure temporal chain is topologically 1D; d̂ should be in [0.5, 1.5]
355        let nodes = make_chain(10);
356        let stats = causal_stats(&nodes);
357        assert!(
358            stats.mm_dimension >= 0.5 && stats.mm_dimension <= 1.5,
359            "1D chain MM dimension expected in [0.5,1.5], got {}",
360            stats.mm_dimension
361        );
362    }
363
364    #[test]
365    fn test_causal_stats_tnet4d_single_site() {
366        // tnet4d(1,1,1,5): 1 site × 5 layers → 5-node chain, C(5,2)=10 pairs
367        let nodes = build_tnet4d(1, 1, 1, 5);
368        let stats = causal_stats(&nodes);
369        assert_eq!(stats.n_nodes, 5);
370        assert_eq!(stats.n_related_pairs, 10, "5-node chain → 10 pairs");
371        assert!(
372            stats.mm_dimension.is_finite(),
373            "MM dimension must be finite"
374        );
375        assert!(
376            stats.mm_dimension >= 0.5 && stats.mm_dimension <= 1.5,
377            "single-site temporal chain should be ~1D, got {}",
378            stats.mm_dimension
379        );
380    }
381
382    #[test]
383    fn test_causal_intervals_tnet4d_parallel() {
384        // tnet4d(2,2,1,3): 4 sites × 3 layers → 4 parallel 3-chains, 4×C(3,2)=12 pairs
385        let nodes = build_tnet4d(2, 2, 1, 3);
386        let intervals = causal_intervals(&nodes);
387        assert_eq!(
388            intervals.len(),
389            12,
390            "4 parallel 3-chains → 12 causal intervals"
391        );
392    }
393}