Skip to main content

geographdb_core/algorithms/
four_d.rs

1//! 4D graph traversal primitives.
2//!
3//! These algorithms treat graph topology, 3D position, and temporal validity as
4//! co-primary filters. They are intentionally standalone so existing CFG
5//! algorithms keep their current API.
6
7use glam::Vec3;
8use std::cmp::Ordering;
9use std::collections::{BTreeMap, BinaryHeap, HashMap, HashSet, VecDeque};
10
11pub type GraphProperties = BTreeMap<String, serde_json::Value>;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct TemporalWindow {
15    pub start: u64,
16    pub end: u64,
17}
18
19impl TemporalWindow {
20    pub fn overlaps(self, begin_ts: u64, end_ts: u64) -> bool {
21        // Safety: Empty or inverted intervals are never valid.
22        // (end_ts == 0 means "until the end of time", so we allow it)
23        if end_ts != 0 && end_ts <= begin_ts {
24            return false;
25        }
26
27        begin_ts < self.end && (end_ts == 0 || end_ts > self.start)
28    }
29}
30
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub enum SpatialRegion {
33    Sphere { center: Vec3, radius: f32 },
34    Aabb { min: Vec3, max: Vec3 },
35}
36
37impl SpatialRegion {
38    pub fn contains(self, point: Vec3) -> bool {
39        match self {
40            SpatialRegion::Sphere { center, radius } => point.distance(center) <= radius,
41            SpatialRegion::Aabb { min, max } => {
42                point.x >= min.x
43                    && point.x <= max.x
44                    && point.y >= min.y
45                    && point.y <= max.y
46                    && point.z >= min.z
47                    && point.z <= max.z
48            }
49        }
50    }
51}
52
53#[derive(Debug, Clone, PartialEq)]
54pub struct TraversalContext4D {
55    pub time_window: Option<TemporalWindow>,
56    pub spatial_region: Option<SpatialRegion>,
57    /// Pre-computed spatial candidates from octree query.
58    /// When set, `node_is_valid` uses O(1) set lookup instead of per-node containment.
59    pub spatial_candidates: Option<HashSet<u64>>,
60    pub graph_weight: f32,
61    pub spatial_weight: f32,
62    pub temporal_weight: f32,
63}
64
65impl Default for TraversalContext4D {
66    fn default() -> Self {
67        Self {
68            time_window: None,
69            spatial_region: None,
70            spatial_candidates: None,
71            graph_weight: 1.0,
72            spatial_weight: 0.0,
73            temporal_weight: 0.0,
74        }
75    }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq)]
79pub struct TemporalEdge {
80    pub dst: u64,
81    pub weight: f32,
82    pub begin_ts: u64,
83    pub end_ts: u64,
84}
85
86#[derive(Debug, Clone, PartialEq)]
87pub struct GraphNode4D {
88    pub id: u64,
89    pub x: f32,
90    pub y: f32,
91    pub z: f32,
92    pub begin_ts: u64,
93    pub end_ts: u64,
94    pub properties: GraphProperties,
95    pub successors: Vec<TemporalEdge>,
96}
97
98impl GraphNode4D {
99    pub fn position(&self) -> Vec3 {
100        Vec3::new(self.x, self.y, self.z)
101    }
102}
103
104#[derive(Debug, Clone, PartialEq)]
105pub struct GraphPath4D {
106    pub node_ids: Vec<u64>,
107    pub total_cost: f32,
108    pub heuristic_cost: f32,
109    pub actual_cost: f32,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct TemporalJourney4D {
114    pub node_ids: Vec<u64>,
115    pub departure_time: u64,
116    pub arrival_time: u64,
117    pub duration: u64,
118}
119
120#[derive(Debug, Clone, PartialEq)]
121pub struct TemporalArrival4D {
122    pub node_id: u64,
123    pub arrival_time: u64,
124    pub cost: f32,
125    pub path: Vec<u64>,
126}
127
128#[derive(Debug, Clone, PartialEq)]
129pub struct TemporalDijkstraResult4D {
130    pub start_node: u64,
131    pub departure_time: u64,
132    pub reachable: Vec<TemporalArrival4D>,
133    pub unreachable: Vec<u64>,
134}
135
136#[derive(Debug, Clone, Copy, PartialEq)]
137struct QueueNode4D {
138    node_id: u64,
139    f_score: f32,
140    g_score: f32,
141}
142
143impl Eq for QueueNode4D {}
144
145impl Ord for QueueNode4D {
146    fn cmp(&self, other: &Self) -> Ordering {
147        other
148            .f_score
149            .partial_cmp(&self.f_score)
150            .unwrap_or(Ordering::Equal)
151    }
152}
153
154impl PartialOrd for QueueNode4D {
155    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
156        Some(self.cmp(other))
157    }
158}
159
160fn node_is_valid(node: &GraphNode4D, ctx: &TraversalContext4D) -> bool {
161    let mut valid = true;
162    if let Some(window) = ctx.time_window {
163        if !window.overlaps(node.begin_ts, node.end_ts) {
164            valid = false;
165        }
166    }
167
168    if valid {
169        match (&ctx.spatial_candidates, ctx.spatial_region) {
170            (Some(candidates), _) => {
171                if !candidates.contains(&node.id) {
172                    valid = false;
173                }
174            }
175            (None, Some(region)) => {
176                if !region.contains(node.position()) {
177                    valid = false;
178                }
179            }
180            (None, None) => {}
181        }
182    }
183
184    valid
185}
186
187fn edge_is_valid(edge: TemporalEdge, ctx: &TraversalContext4D) -> bool {
188    ctx.time_window
189        .map(|window| window.overlaps(edge.begin_ts, edge.end_ts))
190        .unwrap_or(true)
191}
192
193fn instant_in_validity(ts: u64, begin_ts: u64, end_ts: u64) -> bool {
194    ts >= begin_ts && (end_ts == 0 || ts <= end_ts)
195}
196
197fn traversal_duration(edge: TemporalEdge) -> Option<u64> {
198    if !edge.weight.is_finite() || edge.weight < 0.0 {
199        return None;
200    }
201    Some(edge.weight.ceil() as u64)
202}
203
204fn edge_departure_and_arrival(edge: TemporalEdge, current_time: u64) -> Option<(u64, u64)> {
205    let departure = current_time.max(edge.begin_ts);
206    if edge.end_ts != 0 && departure > edge.end_ts {
207        return None;
208    }
209
210    let arrival = departure.checked_add(traversal_duration(edge)?)?;
211    if edge.end_ts != 0 && arrival > edge.end_ts {
212        return None;
213    }
214
215    Some((departure, arrival))
216}
217
218fn temporal_node_is_usable(
219    node: &GraphNode4D,
220    arrival_time: u64,
221    region: Option<SpatialRegion>,
222) -> bool {
223    instant_in_validity(arrival_time, node.begin_ts, node.end_ts)
224        && region
225            .map(|spatial_region| spatial_region.contains(node.position()))
226            .unwrap_or(true)
227}
228
229fn temporal_delay(edge: TemporalEdge, ctx: &TraversalContext4D) -> f32 {
230    match ctx.time_window {
231        Some(window) if edge.begin_ts > window.start => (edge.begin_ts - window.start) as f32,
232        _ => 0.0,
233    }
234}
235
236fn edge_cost(
237    current: &GraphNode4D,
238    next: &GraphNode4D,
239    edge: TemporalEdge,
240    ctx: &TraversalContext4D,
241) -> f32 {
242    let graph = ctx.graph_weight * edge.weight.max(0.0);
243    let spatial = ctx.spatial_weight * current.position().distance(next.position());
244    let temporal = ctx.temporal_weight * temporal_delay(edge, ctx);
245    graph + spatial + temporal
246}
247
248fn heuristic(current: &GraphNode4D, goal: &GraphNode4D, ctx: &TraversalContext4D) -> f32 {
249    ctx.spatial_weight * current.position().distance(goal.position())
250}
251
252pub fn reachable_4d(nodes: &[GraphNode4D], start_id: u64, ctx: &TraversalContext4D) -> Vec<u64> {
253    let node_map: HashMap<u64, &GraphNode4D> = nodes.iter().map(|n| (n.id, n)).collect();
254    let Some(start) = node_map.get(&start_id) else {
255        return Vec::new();
256    };
257    if !node_is_valid(start, ctx) {
258        return Vec::new();
259    }
260
261    let mut visited = HashSet::new();
262    let mut queue = VecDeque::from([start_id]);
263    let mut result = Vec::new();
264
265    while let Some(current_id) = queue.pop_front() {
266        if !visited.insert(current_id) {
267            continue;
268        }
269        let Some(current) = node_map.get(&current_id) else {
270            continue;
271        };
272        if !node_is_valid(current, ctx) {
273            continue;
274        }
275
276        result.push(current_id);
277
278        for edge in &current.successors {
279            if !edge_is_valid(*edge, ctx) || visited.contains(&edge.dst) {
280                continue;
281            }
282            if let Some(next) = node_map.get(&edge.dst) {
283                if node_is_valid(next, ctx) {
284                    queue.push_back(edge.dst);
285                }
286            }
287        }
288    }
289
290    result
291}
292
293pub fn astar_find_path_4d(
294    nodes: &[GraphNode4D],
295    start_id: u64,
296    goal_id: u64,
297    ctx: &TraversalContext4D,
298) -> Option<GraphPath4D> {
299    let node_map: HashMap<u64, &GraphNode4D> = nodes.iter().map(|n| (n.id, n)).collect();
300    let start = *node_map.get(&start_id)?;
301    let goal = *node_map.get(&goal_id)?;
302
303    if !node_is_valid(start, ctx) || !node_is_valid(goal, ctx) {
304        return None;
305    }
306
307    let initial_h = heuristic(start, goal, ctx);
308    let mut open_set = BinaryHeap::from([QueueNode4D {
309        node_id: start_id,
310        f_score: initial_h,
311        g_score: 0.0,
312    }]);
313    let mut closed = HashSet::new();
314    let mut g_score = HashMap::from([(start_id, 0.0)]);
315    let mut came_from = HashMap::new();
316
317    while let Some(current) = open_set.pop() {
318        if !closed.insert(current.node_id) {
319            continue;
320        }
321
322        if current.node_id == goal_id {
323            let mut path = vec![goal_id];
324            let mut cursor = goal_id;
325            while let Some(prev) = came_from.get(&cursor).copied() {
326                path.push(prev);
327                cursor = prev;
328            }
329            path.reverse();
330            return Some(GraphPath4D {
331                node_ids: path,
332                total_cost: current.f_score,
333                heuristic_cost: initial_h,
334                actual_cost: current.g_score,
335            });
336        }
337
338        let current_node = *node_map.get(&current.node_id)?;
339        for edge in &current_node.successors {
340            if !edge_is_valid(*edge, ctx) || closed.contains(&edge.dst) {
341                continue;
342            }
343            let Some(next) = node_map.get(&edge.dst).copied() else {
344                continue;
345            };
346            if !node_is_valid(next, ctx) {
347                continue;
348            }
349
350            let tentative_g = current.g_score + edge_cost(current_node, next, *edge, ctx);
351            let existing_g = g_score.get(&edge.dst).copied().unwrap_or(f32::INFINITY);
352            if tentative_g < existing_g {
353                came_from.insert(edge.dst, current.node_id);
354                g_score.insert(edge.dst, tentative_g);
355                open_set.push(QueueNode4D {
356                    node_id: edge.dst,
357                    f_score: tentative_g + heuristic(next, goal, ctx),
358                    g_score: tentative_g,
359                });
360            }
361        }
362    }
363
364    None
365}
366
367pub fn strongly_connected_components_4d(
368    nodes: &[GraphNode4D],
369    ctx: &TraversalContext4D,
370) -> Vec<Vec<u64>> {
371    struct Tarjan<'a> {
372        nodes: HashMap<u64, &'a GraphNode4D>,
373        ctx: &'a TraversalContext4D,
374        index: usize,
375        stack: Vec<u64>,
376        on_stack: HashSet<u64>,
377        indices: HashMap<u64, usize>,
378        lowlinks: HashMap<u64, usize>,
379        components: Vec<Vec<u64>>,
380    }
381
382    impl<'a> Tarjan<'a> {
383        fn strong_connect(&mut self, node_id: u64) {
384            self.indices.insert(node_id, self.index);
385            self.lowlinks.insert(node_id, self.index);
386            self.index += 1;
387            self.stack.push(node_id);
388            self.on_stack.insert(node_id);
389
390            let Some(node) = self.nodes.get(&node_id) else {
391                return;
392            };
393            for edge in &node.successors {
394                if !edge_is_valid(*edge, self.ctx) {
395                    continue;
396                }
397                let Some(next) = self.nodes.get(&edge.dst).copied() else {
398                    continue;
399                };
400                if !node_is_valid(next, self.ctx) {
401                    continue;
402                }
403
404                if !self.indices.contains_key(&edge.dst) {
405                    self.strong_connect(edge.dst);
406                    let low = self.lowlinks[&node_id].min(self.lowlinks[&edge.dst]);
407                    self.lowlinks.insert(node_id, low);
408                } else if self.on_stack.contains(&edge.dst) {
409                    let low = self.lowlinks[&node_id].min(self.indices[&edge.dst]);
410                    self.lowlinks.insert(node_id, low);
411                }
412            }
413
414            if self.indices[&node_id] == self.lowlinks[&node_id] {
415                let mut component = Vec::new();
416                while let Some(member) = self.stack.pop() {
417                    self.on_stack.remove(&member);
418                    component.push(member);
419                    if member == node_id {
420                        break;
421                    }
422                }
423                component.sort_unstable();
424                self.components.push(component);
425            }
426        }
427    }
428
429    let node_map: HashMap<u64, &GraphNode4D> = nodes
430        .iter()
431        .filter(|node| node_is_valid(node, ctx))
432        .map(|node| (node.id, node))
433        .collect();
434    let mut ids: Vec<u64> = node_map.keys().copied().collect();
435    ids.sort_unstable();
436
437    let mut tarjan = Tarjan {
438        nodes: node_map,
439        ctx,
440        index: 0,
441        stack: Vec::new(),
442        on_stack: HashSet::new(),
443        indices: HashMap::new(),
444        lowlinks: HashMap::new(),
445        components: Vec::new(),
446    };
447
448    for id in ids {
449        if !tarjan.indices.contains_key(&id) {
450            tarjan.strong_connect(id);
451        }
452    }
453
454    tarjan.components.sort_by_key(|component| component[0]);
455    tarjan.components
456}
457
458pub fn earliest_arrival_path_4d(
459    nodes: &[GraphNode4D],
460    start_id: u64,
461    goal_id: u64,
462    departure_time: u64,
463    spatial_region: Option<SpatialRegion>,
464) -> Option<TemporalJourney4D> {
465    let node_map: HashMap<u64, &GraphNode4D> = nodes.iter().map(|node| (node.id, node)).collect();
466    let start = *node_map.get(&start_id)?;
467    if !temporal_node_is_usable(start, departure_time, spatial_region) {
468        return None;
469    }
470
471    let mut best_arrival = HashMap::from([(start_id, departure_time)]);
472    let mut came_from = HashMap::new();
473    let mut queue = BinaryHeap::from([TemporalQueueNode {
474        node_id: start_id,
475        arrival_time: departure_time,
476    }]);
477
478    while let Some(current) = queue.pop() {
479        if current.arrival_time > best_arrival[&current.node_id] {
480            continue;
481        }
482        if current.node_id == goal_id {
483            return Some(reconstruct_temporal_journey(
484                &came_from,
485                start_id,
486                goal_id,
487                departure_time,
488                current.arrival_time,
489            ));
490        }
491
492        let current_node = *node_map.get(&current.node_id)?;
493        for edge in &current_node.successors {
494            let Some(next) = node_map.get(&edge.dst).copied() else {
495                continue;
496            };
497            let Some((_, arrival_time)) = edge_departure_and_arrival(*edge, current.arrival_time)
498            else {
499                continue;
500            };
501            if !temporal_node_is_usable(next, arrival_time, spatial_region) {
502                continue;
503            }
504
505            if arrival_time < best_arrival.get(&edge.dst).copied().unwrap_or(u64::MAX) {
506                best_arrival.insert(edge.dst, arrival_time);
507                came_from.insert(edge.dst, current.node_id);
508                queue.push(TemporalQueueNode {
509                    node_id: edge.dst,
510                    arrival_time,
511                });
512            }
513        }
514    }
515
516    None
517}
518
519pub fn fastest_temporal_path_4d(
520    nodes: &[GraphNode4D],
521    start_id: u64,
522    goal_id: u64,
523    earliest_departure: u64,
524    latest_departure: u64,
525    spatial_region: Option<SpatialRegion>,
526) -> Option<TemporalJourney4D> {
527    const MAX_WINDOW: u64 = 100_000;
528    if earliest_departure > latest_departure {
529        return None;
530    }
531    if latest_departure.saturating_sub(earliest_departure) > MAX_WINDOW {
532        return None;
533    }
534
535    let mut best: Option<TemporalJourney4D> = None;
536
537    for departure_time in earliest_departure..=latest_departure {
538        let Some(journey) =
539            earliest_arrival_path_4d(nodes, start_id, goal_id, departure_time, spatial_region)
540        else {
541            continue;
542        };
543
544        let should_replace = best
545            .as_ref()
546            .map(|current| {
547                journey.duration < current.duration
548                    || (journey.duration == current.duration
549                        && journey.arrival_time < current.arrival_time)
550            })
551            .unwrap_or(true);
552        if should_replace {
553            best = Some(journey);
554        }
555    }
556
557    best
558}
559
560pub fn articulation_points_4d(nodes: &[GraphNode4D], ctx: &TraversalContext4D) -> Vec<u64> {
561    let adjacency = active_undirected_adjacency(nodes, ctx);
562    let mut ids: Vec<u64> = adjacency.keys().copied().collect();
563    ids.sort_unstable();
564
565    let mut finder = LowLinkFinder {
566        adjacency: &adjacency,
567        time: 0,
568        visited: HashSet::new(),
569        discovery: HashMap::new(),
570        low: HashMap::new(),
571        parent: HashMap::new(),
572        articulation_points: HashSet::new(),
573        bridges: Vec::new(),
574    };
575
576    for id in ids {
577        if !finder.visited.contains(&id) {
578            finder.visit(id);
579        }
580    }
581
582    let mut points: Vec<u64> = finder.articulation_points.into_iter().collect();
583    points.sort_unstable();
584    points
585}
586
587pub fn bridges_4d(nodes: &[GraphNode4D], ctx: &TraversalContext4D) -> Vec<(u64, u64)> {
588    let adjacency = active_undirected_adjacency(nodes, ctx);
589    let mut ids: Vec<u64> = adjacency.keys().copied().collect();
590    ids.sort_unstable();
591
592    let mut finder = LowLinkFinder {
593        adjacency: &adjacency,
594        time: 0,
595        visited: HashSet::new(),
596        discovery: HashMap::new(),
597        low: HashMap::new(),
598        parent: HashMap::new(),
599        articulation_points: HashSet::new(),
600        bridges: Vec::new(),
601    };
602
603    for id in ids {
604        if !finder.visited.contains(&id) {
605            finder.visit(id);
606        }
607    }
608
609    finder.bridges.sort_unstable();
610    finder.bridges
611}
612
613pub fn time_dependent_dijkstra_4d(
614    nodes: &[GraphNode4D],
615    start_id: u64,
616    departure_time: u64,
617    spatial_region: Option<SpatialRegion>,
618) -> Option<TemporalDijkstraResult4D> {
619    let node_map: HashMap<u64, &GraphNode4D> = nodes
620        .iter()
621        .filter(|node| {
622            spatial_region
623                .map(|region| region.contains(node.position()))
624                .unwrap_or(true)
625        })
626        .map(|node| (node.id, node))
627        .collect();
628    let start = *node_map.get(&start_id)?;
629    if !instant_in_validity(departure_time, start.begin_ts, start.end_ts) {
630        return None;
631    }
632
633    let mut best_arrival = HashMap::from([(start_id, departure_time)]);
634    let mut came_from = HashMap::new();
635    let mut queue = BinaryHeap::from([TemporalQueueNode {
636        node_id: start_id,
637        arrival_time: departure_time,
638    }]);
639
640    while let Some(current) = queue.pop() {
641        if current.arrival_time > best_arrival[&current.node_id] {
642            continue;
643        }
644
645        let current_node = *node_map.get(&current.node_id)?;
646        for edge in &current_node.successors {
647            let Some(next) = node_map.get(&edge.dst).copied() else {
648                continue;
649            };
650            let Some((_, arrival_time)) = edge_departure_and_arrival(*edge, current.arrival_time)
651            else {
652                continue;
653            };
654            if !instant_in_validity(arrival_time, next.begin_ts, next.end_ts) {
655                continue;
656            }
657
658            if arrival_time < best_arrival.get(&edge.dst).copied().unwrap_or(u64::MAX) {
659                best_arrival.insert(edge.dst, arrival_time);
660                came_from.insert(edge.dst, current.node_id);
661                queue.push(TemporalQueueNode {
662                    node_id: edge.dst,
663                    arrival_time,
664                });
665            }
666        }
667    }
668
669    let mut reachable: Vec<TemporalArrival4D> = best_arrival
670        .iter()
671        .map(|(node_id, arrival_time)| TemporalArrival4D {
672            node_id: *node_id,
673            arrival_time: *arrival_time,
674            cost: arrival_time.saturating_sub(departure_time) as f32,
675            path: reconstruct_node_path(&came_from, start_id, *node_id),
676        })
677        .collect();
678    reachable.sort_by_key(|arrival| (arrival.arrival_time, arrival.node_id));
679
680    let mut unreachable: Vec<u64> = node_map
681        .keys()
682        .copied()
683        .filter(|id| !best_arrival.contains_key(id))
684        .collect();
685    unreachable.sort_unstable();
686
687    Some(TemporalDijkstraResult4D {
688        start_node: start_id,
689        departure_time,
690        reachable,
691        unreachable,
692    })
693}
694
695fn active_undirected_adjacency(
696    nodes: &[GraphNode4D],
697    ctx: &TraversalContext4D,
698) -> HashMap<u64, Vec<u64>> {
699    let node_map: HashMap<u64, &GraphNode4D> = nodes
700        .iter()
701        .filter(|node| node_is_valid(node, ctx))
702        .map(|node| (node.id, node))
703        .collect();
704    let mut adjacency: HashMap<u64, Vec<u64>> = node_map
705        .keys()
706        .copied()
707        .map(|id| (id, Vec::new()))
708        .collect();
709
710    for node in node_map.values() {
711        for edge in &node.successors {
712            if !edge_is_valid(*edge, ctx) || !node_map.contains_key(&edge.dst) {
713                continue;
714            }
715            add_undirected_edge(&mut adjacency, node.id, edge.dst);
716        }
717    }
718
719    for neighbors in adjacency.values_mut() {
720        neighbors.sort_unstable();
721        neighbors.dedup();
722    }
723
724    adjacency
725}
726
727fn add_undirected_edge(adjacency: &mut HashMap<u64, Vec<u64>>, a: u64, b: u64) {
728    if a == b {
729        return;
730    }
731    adjacency.entry(a).or_default().push(b);
732    adjacency.entry(b).or_default().push(a);
733}
734
735struct LowLinkFinder<'a> {
736    adjacency: &'a HashMap<u64, Vec<u64>>,
737    time: usize,
738    visited: HashSet<u64>,
739    discovery: HashMap<u64, usize>,
740    low: HashMap<u64, usize>,
741    parent: HashMap<u64, u64>,
742    articulation_points: HashSet<u64>,
743    bridges: Vec<(u64, u64)>,
744}
745
746impl LowLinkFinder<'_> {
747    fn visit(&mut self, node_id: u64) {
748        self.visited.insert(node_id);
749        self.discovery.insert(node_id, self.time);
750        self.low.insert(node_id, self.time);
751        self.time += 1;
752
753        let mut child_count = 0;
754        let neighbors = self.adjacency.get(&node_id).cloned().unwrap_or_default();
755        for next_id in neighbors {
756            if !self.visited.contains(&next_id) {
757                child_count += 1;
758                self.parent.insert(next_id, node_id);
759                self.visit(next_id);
760
761                let next_low = self.low[&next_id];
762                let node_low = self.low[&node_id].min(next_low);
763                self.low.insert(node_id, node_low);
764
765                let is_root = !self.parent.contains_key(&node_id);
766                if is_root && child_count > 1 {
767                    self.articulation_points.insert(node_id);
768                }
769                if !is_root && next_low >= self.discovery[&node_id] {
770                    self.articulation_points.insert(node_id);
771                }
772                if next_low > self.discovery[&node_id] {
773                    self.bridges
774                        .push((node_id.min(next_id), node_id.max(next_id)));
775                }
776            } else if self.parent.get(&node_id).copied() != Some(next_id) {
777                let node_low = self.low[&node_id].min(self.discovery[&next_id]);
778                self.low.insert(node_id, node_low);
779            }
780        }
781    }
782}
783
784#[derive(Debug, Clone, Copy, PartialEq, Eq)]
785struct TemporalQueueNode {
786    node_id: u64,
787    arrival_time: u64,
788}
789
790impl Ord for TemporalQueueNode {
791    fn cmp(&self, other: &Self) -> Ordering {
792        other
793            .arrival_time
794            .cmp(&self.arrival_time)
795            .then_with(|| other.node_id.cmp(&self.node_id))
796    }
797}
798
799impl PartialOrd for TemporalQueueNode {
800    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
801        Some(self.cmp(other))
802    }
803}
804
805fn reconstruct_temporal_journey(
806    came_from: &HashMap<u64, u64>,
807    start_id: u64,
808    goal_id: u64,
809    departure_time: u64,
810    arrival_time: u64,
811) -> TemporalJourney4D {
812    let mut node_ids = vec![goal_id];
813    let mut cursor = goal_id;
814    while cursor != start_id {
815        let Some(prev) = came_from.get(&cursor).copied() else {
816            break;
817        };
818        node_ids.push(prev);
819        cursor = prev;
820    }
821    node_ids.reverse();
822
823    TemporalJourney4D {
824        node_ids,
825        departure_time,
826        arrival_time,
827        duration: arrival_time.saturating_sub(departure_time),
828    }
829}
830
831fn reconstruct_node_path(came_from: &HashMap<u64, u64>, start_id: u64, target_id: u64) -> Vec<u64> {
832    let mut node_ids = vec![target_id];
833    let mut cursor = target_id;
834    while cursor != start_id {
835        let Some(prev) = came_from.get(&cursor).copied() else {
836            break;
837        };
838        node_ids.push(prev);
839        cursor = prev;
840    }
841    node_ids.reverse();
842    node_ids
843}
844
845/// Spatial index wrapping an octree for pre-filtering graph traversal candidates.
846///
847/// Build once from the node array, reuse across multiple algorithm calls.
848/// Provides O(log n) spatial queries instead of O(n) brute-force containment checks.
849#[derive(Debug, Clone)]
850pub struct SpatialIndex {
851    octree: crate::spatial::octree::Octree,
852}
853
854impl SpatialIndex {
855    /// Build a spatial index from a slice of graph nodes.
856    pub fn from_nodes(nodes: &[GraphNode4D]) -> Self {
857        use crate::storage::data_structures::NodePoint;
858        let points: Vec<NodePoint> = nodes
859            .iter()
860            .map(|n| NodePoint {
861                id: n.id,
862                x: n.x,
863                y: n.y,
864                z: n.z,
865            })
866            .collect();
867        SpatialIndex {
868            octree: crate::spatial::octree::Octree::from_nodes(&points),
869        }
870    }
871
872    /// Pre-filter node IDs matching the spatial region using the octree.
873    ///
874    /// Returns a HashSet of node IDs that fall within the region.
875    /// Use this to populate `TraversalContext4D::spatial_candidates`.
876    pub fn prefilter(&self, region: &SpatialRegion) -> HashSet<u64> {
877        use crate::spatial::octree::BoundingBox;
878        match region {
879            SpatialRegion::Sphere { center, radius } => self
880                .octree
881                .query_sphere(*center, *radius)
882                .into_iter()
883                .map(|np| np.id)
884                .collect(),
885            SpatialRegion::Aabb { min, max } => {
886                let bbox = BoundingBox::new(*min, *max);
887                self.octree
888                    .query_aabb(&bbox)
889                    .into_iter()
890                    .map(|np| np.id)
891                    .collect()
892            }
893        }
894    }
895}
896
897#[cfg(test)]
898mod tests {
899    use super::*;
900
901    fn make_test_nodes() -> Vec<GraphNode4D> {
902        vec![
903            GraphNode4D {
904                id: 1,
905                x: 0.0,
906                y: 0.0,
907                z: 0.0,
908                begin_ts: 0,
909                end_ts: 0,
910                properties: GraphProperties::new(),
911                successors: vec![TemporalEdge {
912                    dst: 2,
913                    weight: 1.0,
914                    begin_ts: 0,
915                    end_ts: 0,
916                }],
917            },
918            GraphNode4D {
919                id: 2,
920                x: 3.0,
921                y: 0.0,
922                z: 0.0,
923                begin_ts: 0,
924                end_ts: 0,
925                properties: GraphProperties::new(),
926                successors: vec![TemporalEdge {
927                    dst: 3,
928                    weight: 1.0,
929                    begin_ts: 0,
930                    end_ts: 0,
931                }],
932            },
933            GraphNode4D {
934                id: 3,
935                x: 10.0,
936                y: 10.0,
937                z: 10.0,
938                begin_ts: 0,
939                end_ts: 0,
940                properties: GraphProperties::new(),
941                successors: vec![],
942            },
943            GraphNode4D {
944                id: 4,
945                x: 50.0,
946                y: 50.0,
947                z: 50.0,
948                begin_ts: 0,
949                end_ts: 0,
950                properties: GraphProperties::new(),
951                successors: vec![],
952            },
953        ]
954    }
955
956    #[test]
957    fn test_spatial_index_prefilter_sphere() {
958        let nodes = make_test_nodes();
959        let index = SpatialIndex::from_nodes(&nodes);
960        let region = SpatialRegion::Sphere {
961            center: Vec3::new(0.0, 0.0, 0.0),
962            radius: 5.0,
963        };
964        let candidates = index.prefilter(&region);
965
966        // Nodes 1 (0,0,0) and 2 (3,0,0) are within radius 5
967        // Node 3 (10,10,10) distance ~17.3 and node 4 (50,50,50) distance ~86.6 are outside
968        assert!(
969            candidates.contains(&1),
970            "node 1 at origin should be in sphere"
971        );
972        assert!(
973            candidates.contains(&2),
974            "node 2 at (3,0,0) should be in sphere"
975        );
976        assert!(
977            !candidates.contains(&3),
978            "node 3 at (10,10,10) should be outside sphere"
979        );
980        assert!(
981            !candidates.contains(&4),
982            "node 4 at (50,50,50) should be outside sphere"
983        );
984    }
985
986    #[test]
987    fn test_spatial_index_prefilter_aabb() {
988        let nodes = make_test_nodes();
989        let index = SpatialIndex::from_nodes(&nodes);
990        let region = SpatialRegion::Aabb {
991            min: Vec3::new(-1.0, -1.0, -1.0),
992            max: Vec3::new(11.0, 11.0, 11.0),
993        };
994        let candidates = index.prefilter(&region);
995
996        // Nodes 1, 2, 3 are within the AABB. Node 4 at (50,50,50) is outside.
997        assert!(candidates.contains(&1), "node 1 should be in AABB");
998        assert!(candidates.contains(&2), "node 2 should be in AABB");
999        assert!(candidates.contains(&3), "node 3 should be in AABB");
1000        assert!(
1001            !candidates.contains(&4),
1002            "node 4 at (50,50,50) should be outside AABB"
1003        );
1004    }
1005
1006    #[test]
1007    fn test_prefilter_matches_manual_containment() {
1008        let nodes = make_test_nodes();
1009        let index = SpatialIndex::from_nodes(&nodes);
1010        let region = SpatialRegion::Sphere {
1011            center: Vec3::new(0.0, 0.0, 0.0),
1012            radius: 12.0,
1013        };
1014
1015        let candidates = index.prefilter(&region);
1016
1017        // Verify against manual containment check
1018        for node in &nodes {
1019            let in_region = region.contains(node.position());
1020            let in_candidates = candidates.contains(&node.id);
1021            assert_eq!(
1022                in_region, in_candidates,
1023                "Mismatch for node {}: manual={} candidates={}",
1024                node.id, in_region, in_candidates
1025            );
1026        }
1027    }
1028
1029    #[test]
1030    fn test_node_is_valid_with_candidates_matches_without() {
1031        let nodes = make_test_nodes();
1032        let index = SpatialIndex::from_nodes(&nodes);
1033        let region = SpatialRegion::Sphere {
1034            center: Vec3::new(0.0, 0.0, 0.0),
1035            radius: 5.0,
1036        };
1037
1038        let ctx_plain = TraversalContext4D {
1039            spatial_region: Some(region),
1040            ..Default::default()
1041        };
1042        let ctx_indexed = TraversalContext4D {
1043            spatial_region: Some(region),
1044            spatial_candidates: Some(index.prefilter(&region)),
1045            ..Default::default()
1046        };
1047
1048        for node in &nodes {
1049            assert_eq!(
1050                node_is_valid(node, &ctx_plain),
1051                node_is_valid(node, &ctx_indexed),
1052                "Mismatch for node {}: plain vs indexed",
1053                node.id
1054            );
1055        }
1056    }
1057
1058    #[test]
1059    fn test_reachable_4d_with_octree_matches_without() {
1060        let nodes = make_test_nodes();
1061        let index = SpatialIndex::from_nodes(&nodes);
1062        let region = SpatialRegion::Sphere {
1063            center: Vec3::new(0.0, 0.0, 0.0),
1064            radius: 5.0,
1065        };
1066
1067        let ctx_plain = TraversalContext4D {
1068            spatial_region: Some(region),
1069            ..Default::default()
1070        };
1071        let ctx_indexed = TraversalContext4D {
1072            spatial_region: Some(region),
1073            spatial_candidates: Some(index.prefilter(&region)),
1074            ..Default::default()
1075        };
1076
1077        let result_plain = reachable_4d(&nodes, 1, &ctx_plain);
1078        let result_indexed = reachable_4d(&nodes, 1, &ctx_indexed);
1079
1080        assert_eq!(
1081            result_plain, result_indexed,
1082            "reachable_4d should produce same results with and without octree"
1083        );
1084    }
1085
1086    #[test]
1087    fn test_astar_with_octree_matches_without() {
1088        let nodes = make_test_nodes();
1089        let index = SpatialIndex::from_nodes(&nodes);
1090        let region = SpatialRegion::Sphere {
1091            center: Vec3::new(0.0, 0.0, 0.0),
1092            radius: 15.0,
1093        };
1094
1095        let ctx_plain = TraversalContext4D {
1096            spatial_region: Some(region),
1097            ..Default::default()
1098        };
1099        let ctx_indexed = TraversalContext4D {
1100            spatial_region: Some(region),
1101            spatial_candidates: Some(index.prefilter(&region)),
1102            ..Default::default()
1103        };
1104
1105        let result_plain = astar_find_path_4d(&nodes, 1, 3, &ctx_plain);
1106        let result_indexed = astar_find_path_4d(&nodes, 1, 3, &ctx_indexed);
1107
1108        assert_eq!(
1109            result_plain, result_indexed,
1110            "astar should produce same results with and without octree"
1111        );
1112    }
1113
1114    #[test]
1115    fn test_no_spatial_region_no_candidates_still_works() {
1116        let nodes = make_test_nodes();
1117        let ctx = TraversalContext4D::default();
1118
1119        // Without spatial_region, all nodes are valid
1120        for node in &nodes {
1121            assert!(
1122                node_is_valid(node, &ctx),
1123                "node {} should be valid without spatial constraints",
1124                node.id
1125            );
1126        }
1127    }
1128}