Skip to main content

condor_grid/algorithms/
hpa_star.rs

1//! Prepared hierarchical grid: HPA* cluster abstraction with local BFS refinement.
2//!
3//! [`HPAStarBuilder`] builds a durable abstract graph; each query adds temporary
4//! endpoints, refines abstract hops, and returns the standard invalid/found/no-path
5//! outcome. It implements both [`HierarchicalGridBuilder`] and
6//! [`PreprocessedGridBuilder`]. Costs are unit hop
7//! counts and preprocessing rejects non-uniform grids. Prefer [`super::jps_plus::JpsPlusBuilder`]
8//! for prepared cardinal search without hierarchical abstraction.
9
10use std::cmp::Ordering;
11use std::collections::{BTreeMap, BinaryHeap, VecDeque};
12
13use crate::{
14    Grid, Path, Point,
15    hierarchical::{HierarchicalGridBuildError, HierarchicalGridBuilder, PreparedHierarchicalGrid},
16    preprocessed_grid::{
17        PreparedGridSearch, PreprocessedGridBuildError, PreprocessedGridBuilder,
18        PreprocessedGridMetadata, metadata_for_grid,
19    },
20    search::{SearchRequest, SearchResult},
21};
22
23/// Hierarchical / preprocessed builder for HPA* on uniform-cost grids.
24///
25/// Implements [`HierarchicalGridBuilder`] and [`PreprocessedGridBuilder`].
26/// Preprocess rejects
27/// non-unit `traversal_cost`. Prefer for multi-query uniform maps with natural
28/// cluster structure; not a weighted-grid solver.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub struct HPAStarBuilder {
31    cluster_size: usize,
32}
33
34impl HPAStarBuilder {
35    /// Builds with the given cluster side length in cells (`cluster_size >= 1`).
36    ///
37    /// Returns [`HierarchicalGridBuildError::InvalidClusterSize`] when `cluster_size == 0`.
38    pub fn new(cluster_size: usize) -> Result<Self, HierarchicalGridBuildError> {
39        if cluster_size == 0 {
40            return Err(HierarchicalGridBuildError::InvalidClusterSize);
41        }
42        Ok(Self { cluster_size })
43    }
44}
45
46impl Default for HPAStarBuilder {
47    fn default() -> Self {
48        Self { cluster_size: 10 }
49    }
50}
51
52impl HierarchicalGridBuilder for HPAStarBuilder {
53    type Map = PreparedHPAStar;
54
55    fn name(&self) -> &'static str {
56        "hpa-star"
57    }
58
59    fn preprocess(&self, grid: &Grid) -> Result<Self::Map, HierarchicalGridBuildError> {
60        ensure_uniform_traversal_costs(grid)?;
61
62        let mut abstract_graph = AbstractGraph::new();
63        let width = grid.width();
64        let height = grid.height();
65
66        for y in (0..height).step_by(self.cluster_size) {
67            for x in (0..width).step_by(self.cluster_size) {
68                if x + self.cluster_size < width {
69                    let x_left = x + self.cluster_size - 1;
70                    let x_right = x + self.cluster_size;
71                    let y_max = (y + self.cluster_size).min(height);
72
73                    let mut current_run = Vec::new();
74                    for yi in y..y_max {
75                        let p_left = Point::new(x_left, yi);
76                        let p_right = Point::new(x_right, yi);
77                        if grid.is_walkable(p_left) && grid.is_walkable(p_right) {
78                            current_run.push(yi);
79                        } else if !current_run.is_empty() {
80                            add_entrances(&mut abstract_graph, x_left, x_right, &current_run, true);
81                            current_run.clear();
82                        }
83                    }
84                    if !current_run.is_empty() {
85                        add_entrances(&mut abstract_graph, x_left, x_right, &current_run, true);
86                    }
87                }
88
89                if y + self.cluster_size < height {
90                    let y_top = y + self.cluster_size - 1;
91                    let y_bottom = y + self.cluster_size;
92                    let x_max = (x + self.cluster_size).min(width);
93
94                    let mut current_run = Vec::new();
95                    for xi in x..x_max {
96                        let p_top = Point::new(xi, y_top);
97                        let p_bottom = Point::new(xi, y_bottom);
98                        if grid.is_walkable(p_top) && grid.is_walkable(p_bottom) {
99                            current_run.push(xi);
100                        } else if !current_run.is_empty() {
101                            add_entrances(
102                                &mut abstract_graph,
103                                y_top,
104                                y_bottom,
105                                &current_run,
106                                false,
107                            );
108                            current_run.clear();
109                        }
110                    }
111                    if !current_run.is_empty() {
112                        add_entrances(&mut abstract_graph, y_top, y_bottom, &current_run, false);
113                    }
114                }
115            }
116        }
117
118        for cy in 0..=((height - 1) / self.cluster_size) {
119            for cx in 0..=((width - 1) / self.cluster_size) {
120                let cluster_entrances: Vec<Point> = abstract_graph
121                    .nodes
122                    .keys()
123                    .filter(|p| p.x / self.cluster_size == cx && p.y / self.cluster_size == cy)
124                    .copied()
125                    .collect();
126
127                for i in 0..cluster_entrances.len() {
128                    for j in (i + 1)..cluster_entrances.len() {
129                        let start = cluster_entrances[i];
130                        let end = cluster_entrances[j];
131                        if let Some(path) =
132                            intra_cluster_search(grid, start, end, self.cluster_size)
133                        {
134                            abstract_graph.add_edge(start, end, path.cost(), path.steps().to_vec());
135                        }
136                    }
137                }
138            }
139        }
140
141        let entrances_by_cluster = index_entrances_by_cluster(&abstract_graph, self.cluster_size);
142
143        Ok(PreparedHPAStar {
144            grid: grid.clone(),
145            cluster_size: self.cluster_size,
146            abstract_graph,
147            entrances_by_cluster,
148            metadata: metadata_for_grid(
149                grid,
150                <Self as HierarchicalGridBuilder>::name(self),
151                <Self as HierarchicalGridBuilder>::name(self),
152            ),
153        })
154    }
155}
156
157impl PreprocessedGridBuilder for HPAStarBuilder {
158    type Map = PreparedHPAStar;
159
160    fn name(&self) -> &'static str {
161        <Self as HierarchicalGridBuilder>::name(self)
162    }
163
164    fn preprocess(&self, grid: &Grid) -> Result<Self::Map, PreprocessedGridBuildError> {
165        <Self as HierarchicalGridBuilder>::preprocess(self, grid)
166            .map_err(PreprocessedGridBuildError::from)
167    }
168}
169
170fn ensure_uniform_traversal_costs(grid: &Grid) -> Result<(), HierarchicalGridBuildError> {
171    for y in 0..grid.height() {
172        for x in 0..grid.width() {
173            let point = Point::new(x, y);
174            if let Some(cost) = grid.traversal_cost(point)
175                && cost != 1
176            {
177                return Err(HierarchicalGridBuildError::NonUniformCost { point, cost });
178            }
179        }
180    }
181
182    Ok(())
183}
184
185fn add_entrances(
186    graph: &mut AbstractGraph,
187    coord1: usize,
188    coord2: usize,
189    run: &[usize],
190    vertical: bool,
191) {
192    if run.is_empty() {
193        return;
194    }
195
196    let points = entrance_sample_indices(run);
197
198    for r in points {
199        let (p1, p2) = if vertical {
200            (Point::new(coord1, r), Point::new(coord2, r))
201        } else {
202            (Point::new(r, coord1), Point::new(r, coord2))
203        };
204        graph.add_edge(p1, p2, 1, vec![p1, p2]);
205    }
206}
207
208/// Sample entrance positions along an opening run (values already sorted).
209///
210/// Condor's unit-cost 4-way HPA\* v0 materializes **all** border cells of each
211/// contiguous opening. Sparse sampling (ends-only / every-k) caused multi-hop
212/// suboptimality on warehouse aisles and open-field-128 story maps.
213fn entrance_sample_indices(run: &[usize]) -> Vec<usize> {
214    run.to_vec()
215}
216
217/// Prepared HPA* map implementing hierarchical and preprocessed search contracts.
218///
219/// Abstract entrance graph plus owned uniform-cost grid. Queries attach start/goal via
220/// temporary intra-cluster edges without mutating durable preprocess state. Path cost is
221/// hop count under unit `traversal_cost` only.
222pub struct PreparedHPAStar {
223    grid: Grid,
224    cluster_size: usize,
225    abstract_graph: AbstractGraph,
226    /// Cluster key `(cx, cy)` → entrance nodes in that cluster (query attach points).
227    entrances_by_cluster: BTreeMap<(usize, usize), Vec<Point>>,
228    metadata: PreprocessedGridMetadata,
229}
230
231impl PreparedHierarchicalGrid for PreparedHPAStar {
232    fn name(&self) -> &'static str {
233        "hpa-star"
234    }
235
236    fn search(&self, request: SearchRequest) -> SearchResult {
237        crate::search::validate_request(&self.grid, request)?;
238        if !self.grid.is_walkable(request.start) || !self.grid.is_walkable(request.goal) {
239            return crate::search::not_found(0);
240        }
241
242        if request.start == request.goal {
243            return crate::search::found(
244                Path::from_steps(vec![request.start]).expect("path contains at least one point"),
245                1,
246            );
247        }
248
249        // Query attaches start/goal with per-request edges only (no abstract-graph clone).
250        let mut temp_edges: BTreeMap<Point, Vec<AbstractEdge>> = BTreeMap::new();
251
252        for &p in &[request.start, request.goal] {
253            let cx = p.x / self.cluster_size;
254            let cy = p.y / self.cluster_size;
255            let cluster_entrances = self
256                .entrances_by_cluster
257                .get(&(cx, cy))
258                .map(Vec::as_slice)
259                .unwrap_or(&[]);
260
261            for &entrance in cluster_entrances {
262                if let Some(path) = intra_cluster_search(&self.grid, p, entrance, self.cluster_size)
263                {
264                    add_temp_edge(
265                        &mut temp_edges,
266                        p,
267                        entrance,
268                        path.cost(),
269                        path.steps().to_vec(),
270                    );
271                }
272            }
273        }
274
275        if request.start.x / self.cluster_size == request.goal.x / self.cluster_size
276            && request.start.y / self.cluster_size == request.goal.y / self.cluster_size
277            && let Some(path) =
278                intra_cluster_search(&self.grid, request.start, request.goal, self.cluster_size)
279        {
280            add_temp_edge(
281                &mut temp_edges,
282                request.start,
283                request.goal,
284                path.cost(),
285                path.steps().to_vec(),
286            );
287        }
288
289        let watch = crate::search::BudgetWatch::start(request.budget);
290        let (dist, parent_map) = match abstract_search(
291            &self.abstract_graph,
292            &temp_edges,
293            request.start,
294            request.goal,
295            &watch,
296        ) {
297            Ok(outcome) => outcome,
298            Err(reason) => return Err(crate::search::budget_error(reason)),
299        };
300
301        if dist == f64::INFINITY {
302            return crate::search::not_found(parent_map.len());
303        }
304
305        let visited_nodes = parent_map.len();
306
307        let mut abstract_path = Vec::new();
308        let mut curr = request.goal;
309        while curr != request.start {
310            let prev = parent_map[&curr];
311            abstract_path.push((prev, curr));
312            curr = prev;
313        }
314        abstract_path.reverse();
315
316        let mut concrete_steps = Vec::new();
317        concrete_steps.push(request.start);
318        for (u, v) in abstract_path {
319            let edge_path = temp_edge_path(&temp_edges, u, v)
320                .or_else(|| self.abstract_graph.get_edge_path(u, v))
321                .expect("abstract parent edge must exist in base or temp edges");
322            // Each stored edge path includes both endpoints; skip the join vertex.
323            let skip = if concrete_steps.last() == Some(&edge_path[0]) {
324                1
325            } else {
326                0
327            };
328            concrete_steps.extend(edge_path.iter().skip(skip));
329        }
330
331        crate::search::found(
332            Path::from_steps(concrete_steps).expect("path contains at least one point"),
333            visited_nodes,
334        )
335    }
336}
337
338impl PreparedGridSearch for PreparedHPAStar {
339    fn name(&self) -> &'static str {
340        <Self as PreparedHierarchicalGrid>::name(self)
341    }
342
343    fn grid(&self) -> &Grid {
344        &self.grid
345    }
346
347    fn metadata(&self) -> &PreprocessedGridMetadata {
348        &self.metadata
349    }
350
351    fn search(&self, request: SearchRequest) -> SearchResult {
352        <Self as PreparedHierarchicalGrid>::search(self, request)
353    }
354}
355
356#[derive(Clone)]
357struct AbstractGraph {
358    nodes: BTreeMap<Point, Vec<AbstractEdge>>,
359}
360
361#[derive(Clone)]
362struct AbstractEdge {
363    to: Point,
364    cost: usize,
365    path: Vec<Point>,
366}
367
368impl AbstractGraph {
369    fn new() -> Self {
370        Self {
371            nodes: BTreeMap::new(),
372        }
373    }
374
375    fn add_edge(&mut self, from: Point, to: Point, cost: usize, path: Vec<Point>) {
376        self.nodes.entry(from).or_default().push(AbstractEdge {
377            to,
378            cost,
379            path: path.clone(),
380        });
381        let mut rev_path = path;
382        rev_path.reverse();
383        self.nodes.entry(to).or_default().push(AbstractEdge {
384            to: from,
385            cost,
386            path: rev_path,
387        });
388    }
389
390    fn get_edge_path(&self, from: Point, to: Point) -> Option<&[Point]> {
391        self.nodes
392            .get(&from)?
393            .iter()
394            .find(|e| e.to == to)
395            .map(|e| e.path.as_slice())
396    }
397}
398
399fn intra_cluster_search(
400    grid: &Grid,
401    start: Point,
402    goal: Point,
403    cluster_size: usize,
404) -> Option<Path> {
405    let cx = start.x / cluster_size;
406    let cy = start.y / cluster_size;
407
408    let mut frontier = VecDeque::from([start]);
409    let mut parents = BTreeMap::from([(start, None)]);
410
411    while let Some(curr) = frontier.pop_front() {
412        if curr == goal {
413            let mut steps = Vec::new();
414            let mut c = Some(curr);
415            while let Some(p) = c {
416                steps.push(p);
417                c = parents[&p];
418            }
419            steps.reverse();
420            return Some(Path::from_steps(steps).expect("path contains at least one point"));
421        }
422
423        for next in grid.neighbors4(curr) {
424            if next.x / cluster_size == cx
425                && next.y / cluster_size == cy
426                && let std::collections::btree_map::Entry::Vacant(e) = parents.entry(next)
427            {
428                e.insert(Some(curr));
429                frontier.push_back(next);
430            }
431        }
432    }
433    None
434}
435
436fn index_entrances_by_cluster(
437    graph: &AbstractGraph,
438    cluster_size: usize,
439) -> BTreeMap<(usize, usize), Vec<Point>> {
440    let mut by_cluster: BTreeMap<(usize, usize), Vec<Point>> = BTreeMap::new();
441    for &p in graph.nodes.keys() {
442        by_cluster
443            .entry((p.x / cluster_size, p.y / cluster_size))
444            .or_default()
445            .push(p);
446    }
447    by_cluster
448}
449
450fn add_temp_edge(
451    temp_edges: &mut BTreeMap<Point, Vec<AbstractEdge>>,
452    from: Point,
453    to: Point,
454    cost: usize,
455    path: Vec<Point>,
456) {
457    temp_edges.entry(from).or_default().push(AbstractEdge {
458        to,
459        cost,
460        path: path.clone(),
461    });
462    let mut rev_path = path;
463    rev_path.reverse();
464    temp_edges.entry(to).or_default().push(AbstractEdge {
465        to: from,
466        cost,
467        path: rev_path,
468    });
469}
470
471fn temp_edge_path(
472    temp_edges: &BTreeMap<Point, Vec<AbstractEdge>>,
473    from: Point,
474    to: Point,
475) -> Option<&[Point]> {
476    temp_edges
477        .get(&from)?
478        .iter()
479        .find(|e| e.to == to)
480        .map(|e| e.path.as_slice())
481}
482
483fn abstract_search(
484    graph: &AbstractGraph,
485    temp_edges: &BTreeMap<Point, Vec<AbstractEdge>>,
486    start: Point,
487    goal: Point,
488    watch: &crate::search::BudgetWatch,
489) -> Result<(f64, BTreeMap<Point, Point>), crate::search::BudgetExhausted> {
490    let mut distances = BTreeMap::new();
491    let mut parents = BTreeMap::new();
492    let mut frontier = BinaryHeap::new();
493    let mut expansions = 0usize;
494
495    distances.insert(start, 0.0);
496    frontier.push(AbstractHeapEntry {
497        point: start,
498        cost: 0.0,
499    });
500
501    while let Some(entry) = frontier.pop() {
502        if entry.point == goal {
503            return Ok((entry.cost, parents));
504        }
505
506        if entry.cost > *distances.get(&entry.point).unwrap_or(&f64::INFINITY) {
507            continue;
508        }
509
510        expansions += 1;
511        watch.check(expansions)?;
512
513        let base = graph
514            .nodes
515            .get(&entry.point)
516            .map(Vec::as_slice)
517            .unwrap_or(&[]);
518        let temp = temp_edges
519            .get(&entry.point)
520            .map(Vec::as_slice)
521            .unwrap_or(&[]);
522        for edge in base.iter().chain(temp.iter()) {
523            let next_dist = entry.cost + edge.cost as f64;
524            if next_dist < *distances.get(&edge.to).unwrap_or(&f64::INFINITY) {
525                distances.insert(edge.to, next_dist);
526                parents.insert(edge.to, entry.point);
527                frontier.push(AbstractHeapEntry {
528                    point: edge.to,
529                    cost: next_dist,
530                });
531            }
532        }
533    }
534
535    Ok((f64::INFINITY, parents))
536}
537
538#[derive(PartialEq)]
539struct AbstractHeapEntry {
540    point: Point,
541    cost: f64,
542}
543
544impl Eq for AbstractHeapEntry {}
545
546impl Ord for AbstractHeapEntry {
547    fn cmp(&self, other: &Self) -> Ordering {
548        other
549            .cost
550            .partial_cmp(&self.cost)
551            .unwrap_or(Ordering::Equal)
552    }
553}
554
555impl PartialOrd for AbstractHeapEntry {
556    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
557        Some(self.cmp(other))
558    }
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564    use crate::{AStar, Cell, Pathfinder, PreparedGridSearch, PreprocessedGridBuilder};
565
566    #[test]
567    fn neutral_adapter_preprocesses_through_the_preprocessed_contract() {
568        let mut grid = Grid::new(6, 4).expect("grid dimensions are valid");
569        grid.set_cell(Point::new(1, 1), Cell::Blocked)
570            .expect("point is in bounds");
571        grid.set_cell(Point::new(4, 2), Cell::Blocked)
572            .expect("point is in bounds");
573
574        let builder = HPAStarBuilder::new(3).expect("cluster size is positive");
575        let prepared = <HPAStarBuilder as PreprocessedGridBuilder>::preprocess(&builder, &grid)
576            .expect("HPA* should preprocess through neutral contract");
577
578        assert_eq!(
579            <HPAStarBuilder as PreprocessedGridBuilder>::name(&builder),
580            "hpa-star"
581        );
582        assert_eq!(PreparedGridSearch::name(&prepared), "hpa-star");
583        let metadata = *prepared.metadata();
584        assert_eq!(metadata.builder_name, "hpa-star");
585        assert_eq!(metadata.query_algorithm, "hpa-star");
586        assert_eq!(metadata.width, 6);
587        assert_eq!(metadata.height, 4);
588        assert_eq!(metadata.cell_count, 24);
589        assert_eq!(metadata.walkable_cell_count, 22);
590        assert_eq!(metadata.blocked_cell_count, 2);
591        assert_eq!(metadata.movement_model, "4-way");
592        assert_eq!(metadata.cost_model, "uniform");
593
594        let request = SearchRequest::new(Point::new(0, 0), Point::new(5, 3));
595        let result = PreparedGridSearch::search(&prepared, request).expect("valid request");
596        assert!(result.is_found());
597        assert_eq!(*prepared.metadata(), metadata);
598        assert!(result.stats().visited_nodes > 0);
599        let path = result.path().expect("path should exist");
600        assert_eq!(path.start(), request.start);
601        assert_eq!(path.goal(), request.goal);
602        assert!(prepared.grid().path_is_walkable(path.steps()));
603    }
604
605    #[test]
606    fn neutral_adapter_matches_the_hierarchical_entrypoint() {
607        let grid = Grid::new(7, 3).expect("grid dimensions are valid");
608        let request = SearchRequest::new(Point::new(0, 1), Point::new(6, 1));
609        let builder = HPAStarBuilder::new(2).expect("cluster size is positive");
610
611        let neutral = <HPAStarBuilder as PreprocessedGridBuilder>::preprocess(&builder, &grid)
612            .expect("HPA* should preprocess through neutral contract");
613        let hierarchical = <HPAStarBuilder as HierarchicalGridBuilder>::preprocess(&builder, &grid)
614            .expect("HPA* should preprocess through hierarchical contract");
615
616        assert_eq!(
617            PreparedGridSearch::search(&neutral, request),
618            PreparedHierarchicalGrid::search(&hierarchical, request)
619        );
620    }
621
622    #[test]
623    fn neutral_adapter_rejects_weighted_grids_before_reporting_unit_cost_paths() {
624        let mut grid = Grid::new(3, 1).expect("grid dimensions are valid");
625        grid.set_traversal_cost(Point::new(1, 0), 5)
626            .expect("walkable weighted cell should accept positive cost");
627        let builder = HPAStarBuilder::new(1).expect("cluster size is positive");
628
629        let neutral_error =
630            match <HPAStarBuilder as PreprocessedGridBuilder>::preprocess(&builder, &grid) {
631                Ok(_) => panic!("neutral HPA* adapter should reject weighted grids"),
632                Err(error) => error,
633            };
634        assert_eq!(
635            neutral_error,
636            PreprocessedGridBuildError::Hierarchical(HierarchicalGridBuildError::NonUniformCost {
637                point: Point::new(1, 0),
638                cost: 5,
639            })
640        );
641
642        let hierarchical_error =
643            match <HPAStarBuilder as HierarchicalGridBuilder>::preprocess(&builder, &grid) {
644                Ok(_) => panic!("hierarchical HPA* builder should reject weighted grids"),
645                Err(error) => error,
646            };
647        assert_eq!(
648            hierarchical_error,
649            HierarchicalGridBuildError::NonUniformCost {
650                point: Point::new(1, 0),
651                cost: 5,
652            }
653        );
654    }
655
656    #[test]
657    fn neutral_adapter_returns_normal_not_found_result() {
658        let mut grid = Grid::new(5, 1).expect("grid dimensions are valid");
659        grid.set_cell(Point::new(2, 0), Cell::Blocked)
660            .expect("point is in bounds");
661
662        let builder = HPAStarBuilder::new(2).expect("cluster size is positive");
663        let prepared = <HPAStarBuilder as PreprocessedGridBuilder>::preprocess(&builder, &grid)
664            .expect("HPA* should preprocess through neutral contract");
665        let result = PreparedGridSearch::search(
666            &prepared,
667            SearchRequest::new(Point::new(0, 0), Point::new(4, 0)),
668        )
669        .expect("valid request");
670
671        assert!(!result.is_found());
672        assert!(result.path().is_none());
673        assert_eq!(result.path().map(|path| path.cost()), None);
674    }
675
676    #[test]
677    fn neutral_adapter_preserves_the_online_pathfinder_surface() {
678        let grid = Grid::new(3, 1).expect("grid dimensions are valid");
679        let request = SearchRequest::new(Point::new(0, 0), Point::new(2, 0));
680        let online = AStar.search(&grid, request).expect("valid request");
681        let prepared = <HPAStarBuilder as PreprocessedGridBuilder>::preprocess(
682            &HPAStarBuilder::new(1).expect("cluster size is positive"),
683            &grid,
684        )
685        .expect("HPA* should preprocess through neutral contract");
686        let prepared_result =
687            PreparedGridSearch::search(&prepared, request).expect("valid request");
688
689        assert!(online.is_found());
690        assert!(prepared_result.is_found());
691        assert_eq!(
692            online.path().map(|path| path.cost()),
693            prepared_result.path().map(|path| path.cost())
694        );
695    }
696}