Skip to main content

geographdb_core/algorithms/
percolation.rs

1//! Spatiotemporal percolation analysis for 4D graphs.
2//!
3//! Measures the phase transition where graph connectivity shifts from fragmented
4//! to connected as the spatial query radius grows. Sweeping both radius and time
5//! produces a 2D phase diagram — the percolation frontier in (radius, time) space.
6
7use crate::algorithms::four_d::{
8    reachable_4d, GraphNode4D, SpatialRegion, TemporalWindow, TraversalContext4D,
9};
10use glam::Vec3;
11
12/// One measurement point in the percolation sweep.
13#[derive(Debug, Clone)]
14pub struct PercolationPoint {
15    /// Query sphere radius at this measurement.
16    pub radius: f32,
17    /// Time slice centre used for the temporal window.
18    pub time: u64,
19    /// Nodes active under (radius, time) constraints.
20    pub active: usize,
21    /// Nodes reachable from the centre node under the same constraints.
22    pub reachable: usize,
23    /// `reachable / active`, or 0.0 when `active == 0`.
24    pub fraction: f32,
25}
26
27/// Sweep a range of radii and time windows over a 4D node set.
28///
29/// For each (radius, time) pair, counts how many nodes are reachable from
30/// `center_id` relative to the total active population. The spatial query is a
31/// sphere of the given radius centred at `sphere_center`.
32///
33/// `time_half_width` is added/subtracted around each time value to form the
34/// temporal window `[time - half, time + half]`.
35pub fn percolation_sweep(
36    nodes: &[GraphNode4D],
37    center_id: u64,
38    sphere_center: Vec3,
39    radii: &[f32],
40    time_slices: &[u64],
41    time_half_width: u64,
42) -> Vec<PercolationPoint> {
43    let mut out = Vec::with_capacity(radii.len() * time_slices.len());
44
45    for &time in time_slices {
46        let t_start = time.saturating_sub(time_half_width);
47        let t_end = time.saturating_add(time_half_width);
48        let time_window = TemporalWindow {
49            start: t_start,
50            end: t_end,
51        };
52
53        for &radius in radii {
54            let ctx = TraversalContext4D {
55                spatial_region: Some(SpatialRegion::Sphere {
56                    center: sphere_center,
57                    radius,
58                }),
59                time_window: Some(time_window),
60                ..TraversalContext4D::default()
61            };
62
63            let active = count_active(nodes, &ctx);
64            let reachable = reachable_4d(nodes, center_id, &ctx).len();
65            let fraction = if active == 0 {
66                0.0
67            } else {
68                reachable as f32 / active as f32
69            };
70
71            out.push(PercolationPoint {
72                radius,
73                time,
74                active,
75                reachable,
76                fraction,
77            });
78        }
79    }
80
81    out
82}
83
84/// Find the critical radius r* at a given time slice.
85///
86/// Returns the smallest radius in `points` (for the given `time`) where
87/// `fraction >= threshold` AND `active >= min_active`. The `min_active` guard
88/// prevents the trivial detection where a single active node yields fraction=1.0
89/// at the smallest non-empty radius.
90///
91/// Returns `None` when no such point exists.
92pub fn find_critical_radius(
93    points: &[PercolationPoint],
94    time: u64,
95    threshold: f32,
96    min_active: usize,
97) -> Option<f32> {
98    points
99        .iter()
100        .filter(|p| p.time == time && p.active >= min_active && p.fraction >= threshold)
101        .map(|p| p.radius)
102        .reduce(f32::min)
103}
104
105// ── helpers (not public, used by tests and the example) ─────────────────────
106
107/// Count nodes that satisfy the traversal context filters without running BFS.
108fn count_active(nodes: &[GraphNode4D], ctx: &TraversalContext4D) -> usize {
109    nodes
110        .iter()
111        .filter(|n| {
112            let time_ok = ctx
113                .time_window
114                .map(|w| w.overlaps(n.begin_ts, n.end_ts))
115                .unwrap_or(true);
116            let space_ok = match ctx.spatial_region {
117                Some(SpatialRegion::Sphere { center, radius }) => {
118                    n.position().distance(center) <= radius
119                }
120                Some(SpatialRegion::Aabb { min, max }) => {
121                    let p = n.position();
122                    p.x >= min.x
123                        && p.x <= max.x
124                        && p.y >= min.y
125                        && p.y <= max.y
126                        && p.z >= min.z
127                        && p.z <= max.z
128                }
129                None => true,
130            };
131            time_ok && space_ok
132        })
133        .count()
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::algorithms::four_d::{GraphNode4D, GraphProperties, TemporalEdge};
140
141    fn node(id: u64, x: f32, y: f32, z: f32, successors: Vec<TemporalEdge>) -> GraphNode4D {
142        GraphNode4D {
143            id,
144            x,
145            y,
146            z,
147            begin_ts: 0,
148            end_ts: 100,
149            properties: GraphProperties::new(),
150            successors,
151        }
152    }
153
154    fn edge(dst: u64) -> TemporalEdge {
155        TemporalEdge {
156            dst,
157            weight: 1.0,
158            begin_ts: 0,
159            end_ts: 100,
160        }
161    }
162
163    /// Build a line graph: 0 → 1 → 2 → … → (n-1), spaced 1.0 apart on the x-axis.
164    fn line_graph(n: u64) -> Vec<GraphNode4D> {
165        (0..n)
166            .map(|i| {
167                let succs = if i + 1 < n { vec![edge(i + 1)] } else { vec![] };
168                node(i, i as f32, 0.0, 0.0, succs)
169            })
170            .collect()
171    }
172
173    // ── percolation_sweep ────────────────────────────────────────────────────
174
175    #[test]
176    fn test_zero_radius_gives_zero_fraction() {
177        // Sphere so small it contains no nodes → active = 0, fraction = 0.
178        let nodes = line_graph(5);
179        let pts = percolation_sweep(
180            &nodes,
181            0,
182            Vec3::new(-10.0, 0.0, 0.0), // centre far away
183            &[0.001],
184            &[50],
185            10,
186        );
187        assert_eq!(pts.len(), 1);
188        assert_eq!(
189            pts[0].active, 0,
190            "no node should fall inside radius 0.001 far from origin"
191        );
192        assert_eq!(pts[0].fraction, 0.0);
193    }
194
195    #[test]
196    fn test_huge_radius_gives_full_fraction() {
197        // Sphere large enough to contain everything → fraction = 1.0.
198        let nodes = line_graph(5);
199        let pts = percolation_sweep(&nodes, 0, Vec3::ZERO, &[1000.0], &[50], 10);
200        assert_eq!(pts.len(), 1);
201        assert_eq!(pts[0].active, 5);
202        assert_eq!(pts[0].reachable, 5);
203        assert!((pts[0].fraction - 1.0).abs() < 1e-6);
204    }
205
206    #[test]
207    fn test_fraction_monotone_with_radius() {
208        // Fraction must be non-decreasing as radius grows (at a fixed time).
209        let nodes = line_graph(10);
210        let radii: Vec<f32> = (1..=20).map(|i| i as f32 * 0.5).collect();
211        let pts = percolation_sweep(&nodes, 0, Vec3::ZERO, &radii, &[50], 10);
212
213        let fracs: Vec<f32> = pts
214            .iter()
215            .filter(|p| p.time == 50)
216            .map(|p| p.fraction)
217            .collect();
218        for w in fracs.windows(2) {
219            assert!(
220                w[1] >= w[0] - 1e-6,
221                "fraction not monotone: {} then {}",
222                w[0],
223                w[1]
224            );
225        }
226    }
227
228    #[test]
229    fn test_time_filter_reduces_active_nodes() {
230        // Nodes only active in [0, 100]; a window outside that range → 0 active.
231        let nodes = line_graph(5);
232        let pts = percolation_sweep(
233            &nodes,
234            0,
235            Vec3::ZERO,
236            &[1000.0],
237            &[500], // time = 500, window [490, 510] — outside node validity [0,100]
238            10,
239        );
240        assert_eq!(pts[0].active, 0);
241        assert_eq!(pts[0].fraction, 0.0);
242    }
243
244    #[test]
245    fn test_critical_radius_found_in_range() {
246        // With a line of 10 nodes spaced 1.0 apart, reachability should cross
247        // 0.5 somewhere between radius 1.0 and radius 10.0.
248        let nodes = line_graph(10);
249        let radii: Vec<f32> = (1..=20).map(|i| i as f32 * 0.5).collect();
250        let pts = percolation_sweep(&nodes, 0, Vec3::ZERO, &radii, &[50], 10);
251        let r_star = find_critical_radius(&pts, 50, 0.5, 1);
252        assert!(r_star.is_some(), "critical radius should be found");
253        let r = r_star.unwrap();
254        assert!(
255            (0.5..=10.0).contains(&r),
256            "r* = {} out of expected range",
257            r
258        );
259    }
260
261    #[test]
262    fn test_find_critical_radius_none_when_never_crosses() {
263        // Threshold 0.5 on an isolated graph (no edges) → fraction = 1/N → None.
264        let isolated: Vec<GraphNode4D> = (0..5)
265            .map(|i| node(i, i as f32, 0.0, 0.0, vec![]))
266            .collect();
267        let pts2 = percolation_sweep(&isolated, 0, Vec3::ZERO, &[1000.0], &[50], 10);
268        // Node 0 can reach only itself → fraction = 1/5 = 0.2
269        let r_star = find_critical_radius(&pts2, 50, 0.5, 1);
270        assert!(
271            r_star.is_none(),
272            "threshold 0.5 should not be crossed when fraction = 0.2"
273        );
274    }
275
276    // ── count_active (internal helper) ──────────────────────────────────────
277
278    #[test]
279    fn test_count_active_no_filter() {
280        let nodes = line_graph(7);
281        let ctx = TraversalContext4D::default();
282        assert_eq!(count_active(&nodes, &ctx), 7);
283    }
284
285    #[test]
286    fn test_count_active_sphere_filter() {
287        let nodes = line_graph(10); // nodes at x = 0..9, y=z=0
288        let ctx = TraversalContext4D {
289            spatial_region: Some(SpatialRegion::Sphere {
290                center: Vec3::ZERO,
291                radius: 3.5,
292            }),
293            ..TraversalContext4D::default()
294        };
295        // Nodes 0,1,2,3 are within radius 3.5 of origin (distances 0,1,2,3)
296        assert_eq!(count_active(&nodes, &ctx), 4);
297    }
298}