Skip to main content

condor_grid/
preprocessed_grid.rs

1//! Algorithm-neutral build-once/query-many contracts for static grid search.
2//!
3//! A [`PreprocessedGridBuilder`] snapshots a [`Grid`] into a [`PreparedGridSearch`];
4//! repeated [`SearchRequest`]s use the usual invalid/found/no-path [`SearchResult`]
5//! without re-binding mutable map state. Rebuild after any map edit. The
6//! [`StaticPreparedGridBuilder`] is the pass-through A* baseline; JPS+, subgoal, and
7//! hierarchical implementations supply their own cost-model constraints. Prefer an
8//! online [`crate::Pathfinder`] for a one-shot or changing grid.
9
10use crate::{
11    AStar, Grid, HierarchicalGridBuildError, Pathfinder, Point,
12    search::{SearchRequest, SearchResult},
13};
14
15/// Preprocesses a static grid into a queryable prepared map.
16///
17/// Prep may be a cheap clone ([`StaticPreparedGridBuilder`]) or an expensive
18/// index build. Failures are typed via [`PreprocessedGridBuildError`].
19pub trait PreprocessedGridBuilder {
20    /// Prepared map type this builder produces.
21    type Map: PreparedGridSearch;
22
23    /// Stable builder identity for reports and portfolios.
24    fn name(&self) -> &'static str;
25
26    /// Snapshot `grid` into a prepared search structure.
27    ///
28    /// # Errors
29    ///
30    /// Returns [`PreprocessedGridBuildError`] when the grid violates the
31    /// algorithm's cost or geometry assumptions.
32    fn preprocess(&self, grid: &Grid) -> Result<Self::Map, PreprocessedGridBuildError>;
33}
34
35/// Prepared static grid that answers repeated point-to-point search requests.
36///
37/// Holds (or views) immutable walkability / cost data from preprocess. Query
38/// cost models follow the concrete algorithm named in metadata
39/// (for example A* on 4-way grids with per-cell `traversal_cost`).
40pub trait PreparedGridSearch {
41    /// Stable map identity (usually the builder name).
42    fn name(&self) -> &'static str;
43
44    /// Grid snapshot used for walkability and costs.
45    fn grid(&self) -> &Grid;
46
47    /// Build-time summary of shape, walkability counts, and cost model labels.
48    fn metadata(&self) -> &PreprocessedGridMetadata;
49
50    /// Point-to-point search on the prepared snapshot.
51    fn search(&self, request: SearchRequest) -> SearchResult;
52}
53
54/// Build-time summary of grid shape, walkability, and movement model.
55///
56/// Recorded once at preprocess for catalogs and multi-query reports; not updated
57/// by individual searches.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub struct PreprocessedGridMetadata {
60    /// Builder that produced the map.
61    pub builder_name: &'static str,
62    /// Online or abstract algorithm used at query time.
63    pub query_algorithm: &'static str,
64    pub width: usize,
65    pub height: usize,
66    pub cell_count: usize,
67    pub walkable_cell_count: usize,
68    pub blocked_cell_count: usize,
69    /// Movement neighborhood label (starter maps use `"4-way"`).
70    pub movement_model: &'static str,
71    /// `"uniform"` when every walkable cell has `traversal_cost == 1`, else `"weighted"`.
72    pub cost_model: &'static str,
73}
74
75/// Error returned when static grid preprocessing fails.
76#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
77#[non_exhaustive]
78pub enum PreprocessedGridBuildError {
79    /// Hierarchical / HPA*-style prep nested under the preprocessed-grid surface.
80    #[error(transparent)]
81    Hierarchical(#[from] HierarchicalGridBuildError),
82    /// Algorithm requires unit costs on every walkable cell.
83    #[error("{algorithm} requires uniform traversal cost 1; cell {point:?} has cost {cost}")]
84    NonUniformCost {
85        algorithm: &'static str,
86        point: Point,
87        cost: usize,
88    },
89}
90
91/// Pass-through builder that clones the grid and delegates queries to [`AStar`].
92///
93/// Establishes the preprocess/query API without abstract indexing. Useful as a
94/// baseline for multi-query amortization measurements against heavier builders.
95#[derive(Debug, Clone, Copy, Default)]
96pub struct StaticPreparedGridBuilder;
97
98impl StaticPreparedGridBuilder {
99    /// Constructs the default pass-through builder.
100    #[must_use]
101    pub const fn new() -> Self {
102        Self
103    }
104}
105
106impl PreprocessedGridBuilder for StaticPreparedGridBuilder {
107    type Map = StaticPreparedGrid;
108
109    fn name(&self) -> &'static str {
110        "static-prepared-grid"
111    }
112
113    fn preprocess(&self, grid: &Grid) -> Result<Self::Map, PreprocessedGridBuildError> {
114        Ok(StaticPreparedGrid {
115            grid: grid.clone(),
116            metadata: metadata_for_grid(grid, self.name(), AStar.name()),
117        })
118    }
119}
120
121/// Immutable grid snapshot with metadata for repeated online search.
122///
123/// Each [`PreparedGridSearch::search`] runs [`AStar`] on the owned clone. No
124/// shared open set or cache is retained between queries.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct StaticPreparedGrid {
127    grid: Grid,
128    metadata: PreprocessedGridMetadata,
129}
130
131impl StaticPreparedGrid {
132    /// Convenience constructor for the matching pass-through builder.
133    #[must_use]
134    pub fn builder() -> StaticPreparedGridBuilder {
135        StaticPreparedGridBuilder
136    }
137}
138
139impl PreparedGridSearch for StaticPreparedGrid {
140    fn name(&self) -> &'static str {
141        self.metadata.builder_name
142    }
143
144    fn grid(&self) -> &Grid {
145        &self.grid
146    }
147
148    fn metadata(&self) -> &PreprocessedGridMetadata {
149        &self.metadata
150    }
151
152    fn search(&self, request: SearchRequest) -> SearchResult {
153        AStar.search(&self.grid, request)
154    }
155}
156
157/// Snapshots shape, walkability counts, and cost-model labels for a prepared map.
158///
159/// Walkable cells are counted by presence of `traversal_cost`; cost model is
160/// `"uniform"` only when every walkable cell has unit cost, else `"weighted"`.
161/// Movement model is always `"4-way"` for this owner crate.
162pub(crate) fn metadata_for_grid(
163    grid: &Grid,
164    builder_name: &'static str,
165    query_algorithm: &'static str,
166) -> PreprocessedGridMetadata {
167    let mut walkable_cell_count = 0usize;
168    let mut weighted = false;
169
170    for y in 0..grid.height() {
171        for x in 0..grid.width() {
172            let point = Point::new(x, y);
173            if let Some(cost) = grid.traversal_cost(point) {
174                walkable_cell_count += 1;
175                weighted |= cost != 1;
176            }
177        }
178    }
179
180    let cell_count = grid.cell_count();
181    PreprocessedGridMetadata {
182        builder_name,
183        query_algorithm,
184        width: grid.width(),
185        height: grid.height(),
186        cell_count,
187        walkable_cell_count,
188        blocked_cell_count: cell_count - walkable_cell_count,
189        movement_model: "4-way",
190        cost_model: if weighted { "weighted" } else { "uniform" },
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::{Cell, Pathfinder};
198
199    #[test]
200    fn static_prepared_grid_records_build_metadata_separately_from_query_stats() {
201        let mut grid = Grid::new(4, 3).expect("grid dimensions are valid");
202        grid.set_cell(Point::new(1, 1), Cell::Blocked)
203            .expect("fixture point is in bounds");
204        grid.set_traversal_cost(Point::new(2, 0), 3)
205            .expect("traversal cost is positive");
206
207        let prepared = StaticPreparedGrid::builder()
208            .preprocess(&grid)
209            .expect("static grid should preprocess");
210        let metadata = *prepared.metadata();
211
212        assert_eq!(metadata.builder_name, "static-prepared-grid");
213        assert_eq!(metadata.query_algorithm, "astar");
214        assert_eq!(metadata.width, 4);
215        assert_eq!(metadata.height, 3);
216        assert_eq!(metadata.cell_count, 12);
217        assert_eq!(metadata.walkable_cell_count, 11);
218        assert_eq!(metadata.blocked_cell_count, 1);
219        assert_eq!(metadata.movement_model, "4-way");
220        assert_eq!(metadata.cost_model, "weighted");
221
222        let result = prepared
223            .search(SearchRequest::new(Point::new(0, 0), Point::new(3, 2)))
224            .expect("request is valid");
225        assert!(result.is_found());
226        assert_eq!(*prepared.metadata(), metadata);
227        assert!(result.visited_nodes() > 0);
228        assert!(result.cost().is_some());
229    }
230
231    #[test]
232    fn static_prepared_grid_queries_an_immutable_snapshot() {
233        let mut grid = Grid::new(5, 1).expect("grid dimensions are valid");
234        let request = SearchRequest::new(Point::new(0, 0), Point::new(4, 0));
235        let prepared = StaticPreparedGrid::builder()
236            .preprocess(&grid)
237            .expect("static grid should preprocess");
238
239        grid.set_cell(Point::new(2, 0), Cell::Blocked)
240            .expect("fixture point is in bounds");
241
242        let prepared_result = prepared.search(request).expect("request is valid");
243        let changed_online_result = AStar.search(&grid, request).expect("request is valid");
244
245        assert!(prepared_result.is_found());
246        assert_eq!(prepared_result.cost(), Some(4));
247        assert!(!changed_online_result.is_found());
248    }
249
250    #[test]
251    fn static_prepared_grid_matches_astar_on_a_uniform_obstacle_course() {
252        let mut grid = Grid::new(5, 3).expect("grid dimensions are valid");
253        grid.set_cell(Point::new(2, 0), Cell::Blocked)
254            .expect("fixture point is in bounds");
255        grid.set_cell(Point::new(2, 2), Cell::Blocked)
256            .expect("fixture point is in bounds");
257        let request = SearchRequest::new(Point::new(0, 1), Point::new(4, 1));
258
259        let prepared = StaticPreparedGrid::builder()
260            .preprocess(&grid)
261            .expect("static grid should preprocess");
262        let prepared_result = prepared.search(request).expect("request is valid");
263        let astar_result = AStar.search(&grid, request).expect("request is valid");
264
265        assert_eq!(prepared_result, astar_result);
266        assert!(
267            prepared_result
268                .path()
269                .is_some_and(|path| prepared.grid().path_is_walkable(path.steps()))
270        );
271    }
272}