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