Skip to main content

condor_grid/algorithms/
subgoal_graph.rs

1//! Prepared-grid sparse subgoal graph over free-space corners.
2//!
3//! [`SubgoalGraphBuilder`] selects subgoals on a uniform-cost grid; each
4//! [`PreparedSubgoalGraph`] query runs abstract A* with lazy BFS edge costs and
5//! returns the standard invalid/found/no-path outcome. Empty, oversized, or failed
6//! abstractions fall back to online [`AStar`]. Prefer [`AStar`] for the ordinary
7//! one-shot route, or [`super::jps_plus::JpsPlusBuilder`] for a durable jump-table lane.
8
9use std::{
10    cmp::Ordering,
11    collections::{BinaryHeap, HashMap, VecDeque},
12};
13
14use crate::{
15    AStar, Grid, Path, Pathfinder, Point,
16    preprocessed_grid::{
17        PreparedGridSearch, PreprocessedGridBuildError, PreprocessedGridBuilder,
18        PreprocessedGridMetadata, metadata_for_grid,
19    },
20    search::{SearchRequest, SearchResult},
21};
22
23/// [`PreprocessedGridBuilder`] for sparse corner subgoal graphs.
24///
25/// Uniform hop cost only (`traversal_cost == 1`). Correctness-first foundation with
26/// online A* fallback when the subgoal set is empty, large, or abstract search fails.
27/// Prefer as a multi-query experiment baseline, not a large-map acceleration claim.
28#[derive(Debug, Clone, Copy, Default)]
29pub struct SubgoalGraphBuilder;
30
31impl SubgoalGraphBuilder {
32    /// Creates a subgoal-graph builder (no configuration knobs in v0).
33    #[must_use]
34    pub const fn new() -> Self {
35        Self
36    }
37}
38
39impl PreprocessedGridBuilder for SubgoalGraphBuilder {
40    type Map = PreparedSubgoalGraph;
41
42    fn name(&self) -> &'static str {
43        "subgoal-graph"
44    }
45
46    fn preprocess(&self, grid: &Grid) -> Result<Self::Map, PreprocessedGridBuildError> {
47        ensure_uniform_traversal_costs(grid)?;
48        let subgoals = select_corner_subgoals(grid);
49        Ok(PreparedSubgoalGraph {
50            grid: grid.clone(),
51            metadata: metadata_for_grid(grid, self.name(), "subgoal-graph-query"),
52            subgoals,
53        })
54    }
55}
56
57/// Prepared subgoal graph implementing [`PreparedGridSearch`].
58///
59/// Sparse corner-subgoal abstract search with online A* fallback; correctness-first,
60/// not a performance claim for huge maps. Uniform hop cost only.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct PreparedSubgoalGraph {
63    grid: Grid,
64    metadata: PreprocessedGridMetadata,
65    subgoals: Vec<Point>,
66}
67
68impl PreparedSubgoalGraph {
69    /// Returns a [`SubgoalGraphBuilder`] for preprocess entry.
70    #[must_use]
71    pub fn builder() -> SubgoalGraphBuilder {
72        SubgoalGraphBuilder
73    }
74
75    /// Number of corner subgoals retained after preprocess.
76    #[must_use]
77    pub fn subgoal_count(&self) -> usize {
78        self.subgoals.len()
79    }
80}
81
82impl PreparedGridSearch for PreparedSubgoalGraph {
83    fn name(&self) -> &'static str {
84        self.metadata.builder_name
85    }
86
87    fn grid(&self) -> &Grid {
88        &self.grid
89    }
90
91    fn metadata(&self) -> &PreprocessedGridMetadata {
92        &self.metadata
93    }
94
95    fn search(&self, request: SearchRequest) -> SearchResult {
96        crate::search::validate_request(&self.grid, request)?;
97        // Keep abstract search sparse: too many corners degrades to online A*.
98        if request.start == request.goal || self.subgoals.is_empty() || self.subgoals.len() > 64 {
99            return AStar.search(&self.grid, request);
100        }
101
102        abstract_search(self, request).unwrap_or_else(|| AStar.search(&self.grid, request))
103    }
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
107enum AbstractNode {
108    Start,
109    Goal,
110    Subgoal(usize),
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
114struct HeapEntry {
115    cost: usize,
116    node: AbstractNode,
117}
118
119impl Ord for HeapEntry {
120    fn cmp(&self, other: &Self) -> Ordering {
121        other
122            .cost
123            .cmp(&self.cost)
124            .then_with(|| node_rank(self.node).cmp(&node_rank(other.node)))
125    }
126}
127
128impl PartialOrd for HeapEntry {
129    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
130        Some(self.cmp(other))
131    }
132}
133
134fn node_rank(node: AbstractNode) -> u8 {
135    match node {
136        AbstractNode::Start => 0,
137        AbstractNode::Goal => 1,
138        AbstractNode::Subgoal(_) => 2,
139    }
140}
141
142fn abstract_search(map: &PreparedSubgoalGraph, request: SearchRequest) -> Option<SearchResult> {
143    let mut distances_from: HashMap<Point, HashMap<Point, usize>> = HashMap::new();
144    let mut best = HashMap::new();
145    let mut parent: HashMap<AbstractNode, AbstractNode> = HashMap::new();
146    let mut heap = BinaryHeap::new();
147
148    best.insert(AbstractNode::Start, 0usize);
149    heap.push(HeapEntry {
150        cost: 0,
151        node: AbstractNode::Start,
152    });
153
154    let mut visited_nodes = 0usize;
155    let watch = crate::search::BudgetWatch::start(request.budget);
156
157    while let Some(HeapEntry { cost, node }) = heap.pop() {
158        if best.get(&node).is_some_and(|&known| cost > known) {
159            continue;
160        }
161        visited_nodes += 1;
162
163        if matches!(node, AbstractNode::Goal) {
164            let points = reconstruct_path(map, request, &parent)?;
165            let path = Path::from_steps(points).ok()?;
166            return Some(crate::search::found(path, visited_nodes));
167        }
168
169        if let Err(reason) = watch.check(visited_nodes) {
170            return Some(Err(crate::search::budget_error(reason)));
171        }
172
173        for (next, step_cost) in outgoing_edges(map, node, request, &mut distances_from) {
174            let next_cost = cost.saturating_add(step_cost);
175            if best.get(&next).is_some_and(|&known| next_cost >= known) {
176                continue;
177            }
178            best.insert(next, next_cost);
179            parent.insert(next, node);
180            heap.push(HeapEntry {
181                cost: next_cost,
182                node: next,
183            });
184        }
185    }
186
187    None
188}
189
190fn outgoing_edges(
191    map: &PreparedSubgoalGraph,
192    node: AbstractNode,
193    request: SearchRequest,
194    distances_from: &mut HashMap<Point, HashMap<Point, usize>>,
195) -> Vec<(AbstractNode, usize)> {
196    let from = match node_point(map, node, request) {
197        Some(point) => point,
198        None => return Vec::new(),
199    };
200
201    let distances = distances_from
202        .entry(from)
203        .or_insert_with(|| bfs_distances(&map.grid, from));
204
205    let mut targets: Vec<(AbstractNode, Point)> = Vec::new();
206    match node {
207        AbstractNode::Start => {
208            for (index, &subgoal) in map.subgoals.iter().enumerate() {
209                targets.push((AbstractNode::Subgoal(index), subgoal));
210            }
211            targets.push((AbstractNode::Goal, request.goal));
212        }
213        AbstractNode::Subgoal(index) => {
214            for (j, &subgoal) in map.subgoals.iter().enumerate() {
215                if j != index {
216                    targets.push((AbstractNode::Subgoal(j), subgoal));
217                }
218            }
219            targets.push((AbstractNode::Goal, request.goal));
220        }
221        AbstractNode::Goal => return Vec::new(),
222    }
223
224    let mut out = Vec::new();
225    for (next, to) in targets {
226        if let Some(cost) = distances.get(&to).copied() {
227            out.push((next, cost));
228        }
229    }
230    out
231}
232
233fn reconstruct_path(
234    map: &PreparedSubgoalGraph,
235    request: SearchRequest,
236    parent: &HashMap<AbstractNode, AbstractNode>,
237) -> Option<Vec<Point>> {
238    let mut chain = vec![AbstractNode::Goal];
239    let mut current = AbstractNode::Goal;
240    while !matches!(current, AbstractNode::Start) {
241        current = *parent.get(&current)?;
242        chain.push(current);
243    }
244    chain.reverse();
245
246    let mut points = Vec::new();
247    for window in chain.windows(2) {
248        let from = node_point(map, window[0], request)?;
249        let to = node_point(map, window[1], request)?;
250        let segment = bfs_path(&map.grid, from, to)?;
251        if points.is_empty() {
252            points = segment;
253        } else {
254            points.extend(segment.into_iter().skip(1));
255        }
256    }
257    Some(points)
258}
259
260fn node_point(
261    map: &PreparedSubgoalGraph,
262    node: AbstractNode,
263    request: SearchRequest,
264) -> Option<Point> {
265    match node {
266        AbstractNode::Start => Some(request.start),
267        AbstractNode::Goal => Some(request.goal),
268        AbstractNode::Subgoal(index) => map.subgoals.get(index).copied(),
269    }
270}
271
272/// Walkable cells that sit at free-space corners relative to obstacles.
273fn select_corner_subgoals(grid: &Grid) -> Vec<Point> {
274    let mut subgoals = Vec::new();
275    let width = grid.width();
276    let height = grid.height();
277
278    for y in 0..height {
279        for x in 0..width {
280            let point = Point::new(x, y);
281            if grid.is_walkable(point) && is_free_space_corner(grid, point) {
282                subgoals.push(point);
283            }
284        }
285    }
286
287    subgoals
288}
289
290fn is_free_space_corner(grid: &Grid, point: Point) -> bool {
291    // 8-neighbor corner pattern: two consecutive cardinals free and the
292    // intervening diagonal blocked (or out of bounds).
293    let dirs = [
294        (1isize, 0isize),
295        (1, 1),
296        (0, 1),
297        (-1, 1),
298        (-1, 0),
299        (-1, -1),
300        (0, -1),
301        (1, -1),
302    ];
303
304    for i in (0..8).step_by(2) {
305        let (c1x, c1y) = dirs[i];
306        let (dx, dy) = dirs[(i + 1) % 8];
307        let (c2x, c2y) = dirs[(i + 2) % 8];
308        let cardinal_a = offset_point(point, c1x, c1y);
309        let diagonal = offset_point(point, dx, dy);
310        let cardinal_b = offset_point(point, c2x, c2y);
311
312        if is_free(grid, cardinal_a)
313            && is_free(grid, cardinal_b)
314            && is_blocked_or_oob(grid, diagonal)
315        {
316            return true;
317        }
318    }
319
320    false
321}
322
323fn offset_point(point: Point, dx: isize, dy: isize) -> Option<Point> {
324    let x = point.x as isize + dx;
325    let y = point.y as isize + dy;
326    if x < 0 || y < 0 {
327        return None;
328    }
329    Some(Point::new(x as usize, y as usize))
330}
331
332fn is_free(grid: &Grid, point: Option<Point>) -> bool {
333    point.is_some_and(|p| grid.index_of(p).is_some() && grid.is_walkable(p))
334}
335
336fn is_blocked_or_oob(grid: &Grid, point: Option<Point>) -> bool {
337    match point {
338        None => true,
339        Some(p) => grid.index_of(p).is_none() || !grid.is_walkable(p),
340    }
341}
342
343fn ensure_uniform_traversal_costs(grid: &Grid) -> Result<(), PreprocessedGridBuildError> {
344    for y in 0..grid.height() {
345        for x in 0..grid.width() {
346            let point = Point::new(x, y);
347            if let Some(cost) = grid.traversal_cost(point)
348                && cost != 1
349            {
350                return Err(PreprocessedGridBuildError::NonUniformCost {
351                    algorithm: "subgoal-graph",
352                    point,
353                    cost,
354                });
355            }
356        }
357    }
358    Ok(())
359}
360
361fn bfs_distances(grid: &Grid, start: Point) -> HashMap<Point, usize> {
362    let mut distances = HashMap::new();
363    if !grid.is_walkable(start) {
364        return distances;
365    }
366    let mut queue = VecDeque::from([start]);
367    distances.insert(start, 0usize);
368
369    while let Some(current) = queue.pop_front() {
370        let current_cost = distances[&current];
371        for neighbor in four_neighbors(current) {
372            if grid.index_of(neighbor).is_none() || !grid.is_walkable(neighbor) {
373                continue;
374            }
375            if distances.contains_key(&neighbor) {
376                continue;
377            }
378            distances.insert(neighbor, current_cost + 1);
379            queue.push_back(neighbor);
380        }
381    }
382
383    distances
384}
385
386fn bfs_path(grid: &Grid, start: Point, goal: Point) -> Option<Vec<Point>> {
387    if start == goal {
388        return Some(vec![start]);
389    }
390    if !grid.is_walkable(start) || !grid.is_walkable(goal) {
391        return None;
392    }
393
394    let mut parent: HashMap<Point, Point> = HashMap::new();
395    let mut queue = VecDeque::from([start]);
396    parent.insert(start, start);
397
398    while let Some(current) = queue.pop_front() {
399        if current == goal {
400            break;
401        }
402        for neighbor in four_neighbors(current) {
403            if grid.index_of(neighbor).is_none() || !grid.is_walkable(neighbor) {
404                continue;
405            }
406            if parent.contains_key(&neighbor) {
407                continue;
408            }
409            parent.insert(neighbor, current);
410            queue.push_back(neighbor);
411        }
412    }
413
414    if !parent.contains_key(&goal) {
415        return None;
416    }
417
418    let mut path = vec![goal];
419    let mut cursor = goal;
420    while cursor != start {
421        cursor = parent[&cursor];
422        path.push(cursor);
423    }
424    path.reverse();
425    Some(path)
426}
427
428fn four_neighbors(point: Point) -> [Point; 4] {
429    [
430        Point::new(point.x.wrapping_sub(1), point.y),
431        Point::new(point.x + 1, point.y),
432        Point::new(point.x, point.y.wrapping_sub(1)),
433        Point::new(point.x, point.y + 1),
434    ]
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use crate::{Cell, SearchRequest};
441
442    #[test]
443    fn subgoal_graph_matches_astar_on_simple_gap() {
444        let mut grid = Grid::new(7, 5).expect("grid");
445        for y in 0..5 {
446            if y != 2 {
447                grid.set_cell(Point::new(3, y), Cell::Blocked)
448                    .expect("valid grid edit");
449            }
450        }
451
452        let prepared = SubgoalGraphBuilder
453            .preprocess(&grid)
454            .expect("preprocess should succeed");
455        let request = SearchRequest::new(Point::new(0, 2), Point::new(6, 2));
456        let subgoal = prepared.search(request);
457        let exact = AStar.search(&grid, request);
458
459        assert!(subgoal.as_ref().expect("valid search request").is_found());
460        assert_eq!(
461            subgoal.as_ref().expect("valid search request").cost(),
462            exact.as_ref().expect("valid search request").cost()
463        );
464    }
465
466    #[test]
467    fn subgoal_graph_rejects_weighted_grids() {
468        let mut grid = Grid::new(4, 3).expect("grid dimensions are valid");
469        grid.set_traversal_cost(Point::new(1, 1), 3)
470            .expect("cost should be valid");
471
472        let error = SubgoalGraphBuilder
473            .preprocess(&grid)
474            .expect_err("weighted grid should fail");
475        assert!(error.to_string().contains("uniform"));
476    }
477
478    #[test]
479    fn subgoal_graph_queries_an_immutable_snapshot() {
480        let mut grid = Grid::new(5, 1).expect("grid dimensions are valid");
481        let request = SearchRequest::new(Point::new(0, 0), Point::new(4, 0));
482        let prepared = SubgoalGraphBuilder
483            .preprocess(&grid)
484            .expect("preprocess should succeed");
485        grid.set_cell(Point::new(2, 0), Cell::Blocked)
486            .expect("cell should be in bounds");
487
488        let prepared_result = prepared.search(request).expect("valid search request");
489        let changed = AStar.search(&grid, request).expect("valid search request");
490
491        assert!(prepared_result.is_found());
492        assert_eq!(prepared_result.path().map(|path| path.cost()), Some(4));
493        assert!(!changed.is_found());
494    }
495
496    #[test]
497    fn subgoal_graph_matches_astar_on_a_uniform_obstacle_course() {
498        let mut grid = Grid::new(7, 5).expect("grid dimensions are valid");
499        for y in 0..5 {
500            if y != 2 {
501                grid.set_cell(Point::new(3, y), Cell::Blocked)
502                    .expect("fixture point is in bounds");
503            }
504        }
505        let request = SearchRequest::new(Point::new(0, 2), Point::new(6, 2));
506
507        let prepared = SubgoalGraphBuilder
508            .preprocess(&grid)
509            .expect("subgoal preprocessing succeeds");
510        let prepared_result = prepared.search(request).expect("request is valid");
511        let exact = AStar.search(&grid, request).expect("request is valid");
512
513        assert_eq!(prepared_result.cost(), exact.cost());
514        assert_eq!(prepared_result.is_found(), exact.is_found());
515        assert!(
516            prepared_result
517                .path()
518                .is_some_and(|path| prepared.grid().path_is_walkable(path.steps()))
519        );
520    }
521}