Skip to main content

condor_grid/algorithms/
astar.rs

1//! Static-grid [`Pathfinder`]: weighted 4-connected A*.
2//!
3//! Each search is independent and uses the standard invalid/found/no-path contract.
4//! Cost sums per-cell [`traversal_cost`](crate::Grid::traversal_cost) with a Manhattan
5//! heuristic. Prefer this curated default for one static weighted query; use
6//! [`crate::PreparedGridSearch`] for repeated static queries or [`super::dijkstra::Dijkstra`]
7//! as the no-heuristic weighted baseline.
8//!
9//! # Examples
10//!
11//! ```
12//! use condor_grid::{AStar, Cell, Grid, Pathfinder, Point, SearchRequest};
13//!
14//! let mut grid = Grid::new(3, 3).expect("grid dimensions are valid");
15//! grid.set_cell(Point::new(1, 1), Cell::Blocked)
16//!     .expect("point is in bounds");
17//! let request = SearchRequest::new(Point::new(0, 0), Point::new(2, 2));
18//! let result = AStar.search(&grid, request).expect("request is valid");
19//! assert!(result.is_found());
20//! ```
21use std::{
22    cmp::Ordering,
23    collections::{BTreeMap, BinaryHeap},
24};
25
26use crate::{
27    grid::Grid,
28    path::Path,
29    point::Point,
30    search::{BudgetWatch, Pathfinder, SearchRequest, SearchResult},
31};
32
33/// Weighted static-grid [`Pathfinder`]: A* with Manhattan heuristic.
34///
35/// Cost model: sums per-cell `traversal_cost` on 4-connected edges. Prefer as the
36/// default online weighted baseline; use BFS family for pure hop count, JPS/JPS+ for
37/// open-room cardinal pruning, Dijkstra when no heuristic is wanted.
38#[derive(Debug, Default, Clone, Copy)]
39pub struct AStar;
40
41/// Open-set and relaxation counters from an instrumented [`AStar::inspect`] run.
42///
43/// Bakeoff/inspect only; raw event tallies, not normalized rates. Not part of
44/// public [`crate::SearchStats`].
45#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
46pub struct AStarDiagnostics {
47    pub frontier_pushes: usize,
48    pub frontier_pops: usize,
49    pub stale_pops_skipped: usize,
50    pub stale_pops_before_goal_discovery: usize,
51    pub stale_pops_after_goal_discovery: usize,
52    pub stale_pops_after_goal_discovery_below_goal_cost: usize,
53    pub stale_pops_after_goal_discovery_at_goal_cost: usize,
54    pub stale_pops_after_goal_discovery_above_goal_cost: usize,
55    pub peak_frontier_len: usize,
56    pub relaxation_attempts: usize,
57    pub relaxation_attempts_after_goal_discovery: usize,
58    pub relaxations_accepted: usize,
59    pub relaxations_accepted_after_goal_discovery: usize,
60    pub relaxations_accepted_after_goal_discovery_first_touch: usize,
61    pub relaxations_accepted_after_goal_discovery_improved: usize,
62    pub relaxations_accepted_after_goal_discovery_first_touch_slack_zero: usize,
63    pub relaxations_accepted_after_goal_discovery_first_touch_slack_1_to_4: usize,
64    pub relaxations_accepted_after_goal_discovery_first_touch_slack_5_to_16: usize,
65    pub relaxations_accepted_after_goal_discovery_first_touch_slack_17_plus: usize,
66    pub relaxations_accepted_after_goal_discovery_first_touch_slack_sum: usize,
67    pub relaxations_accepted_after_goal_discovery_first_touch_max_slack: usize,
68    pub distinct_estimated_total_costs_popped: usize,
69    pub max_equal_f_pop_run: usize,
70    pub goal_cost_plateau_pops: usize,
71    pub goal_first_discovery_visited_nodes: Option<usize>,
72    pub goal_first_discovery_path_cost: Option<usize>,
73    pub goal_first_discovery_frontier_len: Option<usize>,
74    pub goal_first_discovery_frontier_below_goal_cost: Option<usize>,
75    pub goal_first_discovery_frontier_at_goal_cost: Option<usize>,
76    pub goal_first_discovery_frontier_above_goal_cost: Option<usize>,
77    pub visited_nodes_before_goal_discovery: usize,
78    pub visited_nodes_after_goal_discovery: usize,
79    pub visited_nodes_after_goal_discovery_below_goal_cost: usize,
80    pub visited_nodes_after_goal_discovery_at_goal_cost: usize,
81    pub visited_nodes_after_goal_discovery_above_goal_cost: usize,
82    pub heuristic_slack_zero_pops: usize,
83    pub heuristic_slack_1_to_4_pops: usize,
84    pub heuristic_slack_5_to_16_pops: usize,
85    pub heuristic_slack_17_plus_pops: usize,
86    pub heuristic_slack_sum: usize,
87    pub heuristic_slack_sum_before_goal_discovery: usize,
88    pub heuristic_slack_sum_after_goal_discovery: usize,
89    pub max_heuristic_slack: usize,
90}
91
92/// A* search result paired with [`AStarDiagnostics`] from one instrumented run.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct AStarInspection {
95    /// Standard found/no-path or invalid/budget result.
96    pub result: SearchResult,
97    /// Open-set and relaxation tallies for this inspect run.
98    pub diagnostics: AStarDiagnostics,
99}
100
101impl AStar {
102    /// Runs an instrumented search. Invalid endpoints are reported through
103    /// [`AStarInspection::result`] with the same errors as [`Pathfinder::search`].
104    #[must_use]
105    pub fn inspect(&self, grid: &Grid, request: SearchRequest) -> AStarInspection {
106        if let Err(error) = crate::search::validate_request(grid, request) {
107            return AStarInspection {
108                result: Err(error),
109                diagnostics: AStarDiagnostics::default(),
110            };
111        }
112        let execution = search_impl::<true>(grid, request);
113        AStarInspection {
114            result: execution.result,
115            diagnostics: execution.diagnostics,
116        }
117    }
118}
119
120impl Pathfinder for AStar {
121    fn name(&self) -> &'static str {
122        "astar"
123    }
124
125    fn search(&self, grid: &Grid, request: SearchRequest) -> SearchResult {
126        crate::search::validate_request(grid, request)?;
127        search_impl::<false>(grid, request).result
128    }
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
132struct SearchExecution {
133    result: SearchResult,
134    diagnostics: AStarDiagnostics,
135}
136
137fn search_impl<const TRACK_DIAGNOSTICS: bool>(
138    grid: &Grid,
139    request: SearchRequest,
140) -> SearchExecution {
141    let mut diagnostics = AStarDiagnostics::default();
142
143    let Some(start_index) = grid.index_of(request.start) else {
144        return SearchExecution {
145            result: crate::search::not_found(0),
146            diagnostics,
147        };
148    };
149    let Some(goal_index) = grid.index_of(request.goal) else {
150        return SearchExecution {
151            result: crate::search::not_found(0),
152            diagnostics,
153        };
154    };
155
156    if !grid.is_walkable(request.start) || !grid.is_walkable(request.goal) {
157        return SearchExecution {
158            result: crate::search::not_found(0),
159            diagnostics,
160        };
161    }
162
163    if !grid.is_reachable(request.start, request.goal) {
164        return SearchExecution {
165            result: crate::search::not_found(0),
166            diagnostics,
167        };
168    }
169
170    if request.start == request.goal {
171        return SearchExecution {
172            result: crate::search::found(
173                Path::from_steps(vec![request.start]).expect("path contains at least one point"),
174                1,
175            ),
176            diagnostics,
177        };
178    }
179
180    let initial_heuristic = manhattan_distance(request.start, request.goal);
181    let mut frontier = BinaryHeap::from([FrontierEntry {
182        heuristic_cost: initial_heuristic,
183        estimated_total_cost: initial_heuristic,
184        cost_so_far: 0,
185        index: start_index,
186    }]);
187    let mut best_costs = vec![None; grid.cell_count()];
188    let mut parents = vec![None; grid.cell_count()];
189    let mut visited_nodes = 0;
190    let mut last_popped_estimated_total_cost = None;
191    let mut current_equal_f_pop_run = 0usize;
192    let mut popped_by_estimated_total_cost = TRACK_DIAGNOSTICS.then(BTreeMap::new);
193    let goal_distance_map =
194        TRACK_DIAGNOSTICS.then(|| reverse_cost_map_from_goal(grid, request.goal));
195    let watch = BudgetWatch::start(request.budget);
196
197    best_costs[start_index] = Some(0);
198
199    if TRACK_DIAGNOSTICS {
200        diagnostics.frontier_pushes = 1;
201        diagnostics.peak_frontier_len = 1;
202    }
203
204    while let Some(entry) = frontier.pop() {
205        if TRACK_DIAGNOSTICS {
206            diagnostics.frontier_pops += 1;
207        }
208
209        if best_costs[entry.index] != Some(entry.cost_so_far) {
210            if TRACK_DIAGNOSTICS {
211                diagnostics.stale_pops_skipped += 1;
212                if let Some(goal_cost) = diagnostics.goal_first_discovery_path_cost {
213                    diagnostics.stale_pops_after_goal_discovery += 1;
214                    increment_goal_cost_band(
215                        entry.estimated_total_cost,
216                        goal_cost,
217                        &mut diagnostics.stale_pops_after_goal_discovery_below_goal_cost,
218                        &mut diagnostics.stale_pops_after_goal_discovery_at_goal_cost,
219                        &mut diagnostics.stale_pops_after_goal_discovery_above_goal_cost,
220                    );
221                } else {
222                    diagnostics.stale_pops_before_goal_discovery += 1;
223                }
224            }
225            continue;
226        }
227
228        let before_goal_discovery =
229            TRACK_DIAGNOSTICS && diagnostics.goal_first_discovery_visited_nodes.is_none();
230
231        if TRACK_DIAGNOSTICS {
232            if last_popped_estimated_total_cost == Some(entry.estimated_total_cost) {
233                current_equal_f_pop_run += 1;
234            } else {
235                last_popped_estimated_total_cost = Some(entry.estimated_total_cost);
236                current_equal_f_pop_run = 1;
237                diagnostics.distinct_estimated_total_costs_popped += 1;
238            }
239
240            diagnostics.max_equal_f_pop_run =
241                diagnostics.max_equal_f_pop_run.max(current_equal_f_pop_run);
242
243            if let Some(histogram) = &mut popped_by_estimated_total_cost {
244                *histogram.entry(entry.estimated_total_cost).or_default() += 1;
245            }
246        }
247
248        let current = grid.point_from_index(entry.index);
249        if let Some(goal_distances) = &goal_distance_map
250            && let Some(true_remaining_cost) = goal_distances[entry.index]
251        {
252            let heuristic_slack =
253                true_remaining_cost.saturating_sub(manhattan_distance(current, request.goal));
254            diagnostics.heuristic_slack_sum += heuristic_slack;
255            if before_goal_discovery {
256                diagnostics.heuristic_slack_sum_before_goal_discovery += heuristic_slack;
257            } else {
258                diagnostics.heuristic_slack_sum_after_goal_discovery += heuristic_slack;
259            }
260            diagnostics.max_heuristic_slack = diagnostics.max_heuristic_slack.max(heuristic_slack);
261
262            match heuristic_slack {
263                0 => diagnostics.heuristic_slack_zero_pops += 1,
264                1..=4 => diagnostics.heuristic_slack_1_to_4_pops += 1,
265                5..=16 => diagnostics.heuristic_slack_5_to_16_pops += 1,
266                _ => diagnostics.heuristic_slack_17_plus_pops += 1,
267            }
268        }
269
270        visited_nodes += 1;
271        if TRACK_DIAGNOSTICS {
272            if before_goal_discovery {
273                diagnostics.visited_nodes_before_goal_discovery += 1;
274            } else {
275                diagnostics.visited_nodes_after_goal_discovery += 1;
276                let goal_cost = diagnostics
277                    .goal_first_discovery_path_cost
278                    .expect("post-goal counters require a discovered goal cost");
279                increment_goal_cost_band(
280                    entry.estimated_total_cost,
281                    goal_cost,
282                    &mut diagnostics.visited_nodes_after_goal_discovery_below_goal_cost,
283                    &mut diagnostics.visited_nodes_after_goal_discovery_at_goal_cost,
284                    &mut diagnostics.visited_nodes_after_goal_discovery_above_goal_cost,
285                );
286            }
287        }
288
289        if entry.index == goal_index {
290            break;
291        }
292
293        if let Err(reason) = watch.check(visited_nodes) {
294            return SearchExecution {
295                result: Err(crate::search::budget_error(reason)),
296                diagnostics,
297            };
298        }
299
300        for neighbor in ordered_neighbors4(grid, current, request.goal)
301            .into_iter()
302            .flatten()
303        {
304            let after_goal_discovery =
305                TRACK_DIAGNOSTICS && diagnostics.goal_first_discovery_path_cost.is_some();
306
307            if TRACK_DIAGNOSTICS {
308                diagnostics.relaxation_attempts += 1;
309                if after_goal_discovery {
310                    diagnostics.relaxation_attempts_after_goal_discovery += 1;
311                }
312            }
313
314            let neighbor_index = grid
315                .index_of(neighbor)
316                .expect("walkable neighbors must exist inside the grid");
317            let edge_cost = grid
318                .traversal_cost(neighbor)
319                .expect("walkable neighbors must have a traversal cost");
320            let Some(next_cost) = entry.cost_so_far.checked_add(edge_cost) else {
321                continue;
322            };
323            let was_unseen = best_costs[neighbor_index].is_none();
324
325            if best_costs[neighbor_index].is_some_and(|best_cost| next_cost >= best_cost) {
326                continue;
327            }
328
329            let heuristic_cost = manhattan_distance(neighbor, request.goal);
330            let estimated_total_cost = next_cost.saturating_add(heuristic_cost);
331
332            best_costs[neighbor_index] = Some(next_cost);
333            parents[neighbor_index] = Some(entry.index);
334            let first_goal_discovery = TRACK_DIAGNOSTICS
335                && neighbor_index == goal_index
336                && diagnostics.goal_first_discovery_visited_nodes.is_none();
337            frontier.push(FrontierEntry {
338                heuristic_cost,
339                estimated_total_cost,
340                cost_so_far: next_cost,
341                index: neighbor_index,
342            });
343            if first_goal_discovery {
344                diagnostics.goal_first_discovery_visited_nodes = Some(visited_nodes);
345                diagnostics.goal_first_discovery_path_cost = Some(next_cost);
346                diagnostics.goal_first_discovery_frontier_len = Some(frontier.len());
347                let (below, at, above) = frontier_goal_cost_band_counts(&frontier, next_cost);
348                diagnostics.goal_first_discovery_frontier_below_goal_cost = Some(below);
349                diagnostics.goal_first_discovery_frontier_at_goal_cost = Some(at);
350                diagnostics.goal_first_discovery_frontier_above_goal_cost = Some(above);
351            }
352
353            if TRACK_DIAGNOSTICS {
354                diagnostics.relaxations_accepted += 1;
355                if after_goal_discovery {
356                    diagnostics.relaxations_accepted_after_goal_discovery += 1;
357                    if was_unseen {
358                        diagnostics.relaxations_accepted_after_goal_discovery_first_touch += 1;
359                        if let Some(goal_distances) = &goal_distance_map
360                            && let Some(true_remaining_cost) = goal_distances[neighbor_index]
361                        {
362                            let slack = true_remaining_cost
363                                .saturating_sub(manhattan_distance(neighbor, request.goal));
364                            diagnostics
365                                .relaxations_accepted_after_goal_discovery_first_touch_slack_sum +=
366                                slack;
367                            diagnostics
368                                .relaxations_accepted_after_goal_discovery_first_touch_max_slack =
369                                diagnostics
370                                    .relaxations_accepted_after_goal_discovery_first_touch_max_slack
371                                    .max(slack);
372                            match slack {
373                                0 => {
374                                    diagnostics
375                                        .relaxations_accepted_after_goal_discovery_first_touch_slack_zero +=
376                                        1
377                                }
378                                1..=4 => {
379                                    diagnostics
380                                        .relaxations_accepted_after_goal_discovery_first_touch_slack_1_to_4 +=
381                                        1
382                                }
383                                5..=16 => {
384                                    diagnostics
385                                        .relaxations_accepted_after_goal_discovery_first_touch_slack_5_to_16 +=
386                                        1
387                                }
388                                _ => {
389                                    diagnostics
390                                        .relaxations_accepted_after_goal_discovery_first_touch_slack_17_plus +=
391                                        1
392                                }
393                            }
394                        }
395                    } else {
396                        diagnostics.relaxations_accepted_after_goal_discovery_improved += 1;
397                    }
398                }
399                diagnostics.frontier_pushes += 1;
400                diagnostics.peak_frontier_len = diagnostics.peak_frontier_len.max(frontier.len());
401            }
402        }
403    }
404
405    let result = if let Some(goal_cost) = best_costs[goal_index] {
406        if let Some(histogram) = popped_by_estimated_total_cost {
407            diagnostics.goal_cost_plateau_pops = histogram.get(&goal_cost).copied().unwrap_or(0);
408        }
409
410        crate::search::found(
411            reconstruct_path(grid, &parents, start_index, goal_index, goal_cost),
412            visited_nodes,
413        )
414    } else {
415        crate::search::not_found(visited_nodes)
416    };
417
418    SearchExecution {
419        result,
420        diagnostics,
421    }
422}
423
424#[derive(Debug, Clone, Copy, PartialEq, Eq)]
425struct FrontierEntry {
426    heuristic_cost: usize,
427    estimated_total_cost: usize,
428    cost_so_far: usize,
429    index: usize,
430}
431
432impl Ord for FrontierEntry {
433    fn cmp(&self, other: &Self) -> Ordering {
434        other
435            .estimated_total_cost
436            .cmp(&self.estimated_total_cost)
437            .then_with(|| other.heuristic_cost.cmp(&self.heuristic_cost))
438            .then_with(|| self.cost_so_far.cmp(&other.cost_so_far))
439            .then_with(|| other.index.cmp(&self.index))
440    }
441}
442
443impl PartialOrd for FrontierEntry {
444    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
445        Some(self.cmp(other))
446    }
447}
448
449fn reconstruct_path(
450    grid: &Grid,
451    parents: &[Option<usize>],
452    start_index: usize,
453    goal_index: usize,
454    total_cost: usize,
455) -> Path {
456    let mut current_index = goal_index;
457    let mut steps = vec![grid.point_from_index(goal_index)];
458
459    while current_index != start_index {
460        current_index =
461            parents[current_index].expect("a discovered goal must have a complete parent chain");
462        steps.push(grid.point_from_index(current_index));
463    }
464
465    steps.reverse();
466    Path::from_steps_with_cost(steps, total_cost).expect("path contains at least one point")
467}
468
469fn reverse_cost_map_from_goal(grid: &Grid, goal: Point) -> Vec<Option<usize>> {
470    let mut costs = vec![None; grid.cell_count()];
471    let Some(goal_index) = grid.index_of(goal) else {
472        return costs;
473    };
474    if !grid.is_walkable(goal) {
475        return costs;
476    }
477
478    let mut frontier = BinaryHeap::from([FrontierEntry {
479        heuristic_cost: 0,
480        estimated_total_cost: 0,
481        cost_so_far: 0,
482        index: goal_index,
483    }]);
484    costs[goal_index] = Some(0);
485
486    while let Some(entry) = frontier.pop() {
487        if costs[entry.index] != Some(entry.cost_so_far) {
488            continue;
489        }
490
491        let index = entry.index;
492        let point = grid.point_from_index(index);
493        let cost_from_point = costs[index].expect("queued nodes must have a cost");
494        let reverse_edge_cost = grid
495            .traversal_cost(point)
496            .expect("walkable reverse frontier nodes must have a traversal cost");
497
498        for neighbor in grid.neighbors4(point) {
499            let neighbor_index = grid
500                .index_of(neighbor)
501                .expect("walkable neighbors must exist inside the grid");
502            let Some(next_cost) = cost_from_point.checked_add(reverse_edge_cost) else {
503                continue;
504            };
505            if costs[neighbor_index].is_some_and(|best| next_cost >= best) {
506                continue;
507            }
508
509            costs[neighbor_index] = Some(next_cost);
510            frontier.push(FrontierEntry {
511                heuristic_cost: 0,
512                estimated_total_cost: next_cost,
513                cost_so_far: next_cost,
514                index: neighbor_index,
515            });
516        }
517    }
518
519    costs
520}
521
522fn frontier_goal_cost_band_counts(
523    frontier: &BinaryHeap<FrontierEntry>,
524    goal_cost: usize,
525) -> (usize, usize, usize) {
526    let mut below = 0usize;
527    let mut at = 0usize;
528    let mut above = 0usize;
529
530    for entry in frontier {
531        increment_goal_cost_band(
532            entry.estimated_total_cost,
533            goal_cost,
534            &mut below,
535            &mut at,
536            &mut above,
537        );
538    }
539
540    (below, at, above)
541}
542
543fn increment_goal_cost_band(
544    estimated_total_cost: usize,
545    goal_cost: usize,
546    below: &mut usize,
547    at: &mut usize,
548    above: &mut usize,
549) {
550    match estimated_total_cost.cmp(&goal_cost) {
551        Ordering::Less => *below += 1,
552        Ordering::Equal => *at += 1,
553        Ordering::Greater => *above += 1,
554    }
555}
556
557fn manhattan_distance(from: Point, to: Point) -> usize {
558    from.x.abs_diff(to.x) + from.y.abs_diff(to.y)
559}
560
561fn ordered_neighbors4(grid: &Grid, current: Point, goal: Point) -> [Option<Point>; 4] {
562    let mut ordered = [None; 4];
563    let mut count = 0;
564
565    let mut push_direction = |direction| {
566        if let Some(candidate) = step(current, direction)
567            && grid.is_walkable(candidate)
568            && !ordered[..count]
569                .iter()
570                .flatten()
571                .any(|point| *point == candidate)
572        {
573            ordered[count] = Some(candidate);
574            count += 1;
575        }
576    };
577
578    match current.x.cmp(&goal.x) {
579        Ordering::Less => push_direction(Direction::Right),
580        Ordering::Greater => push_direction(Direction::Left),
581        Ordering::Equal => {}
582    }
583
584    match current.y.cmp(&goal.y) {
585        Ordering::Less => push_direction(Direction::Down),
586        Ordering::Greater => push_direction(Direction::Up),
587        Ordering::Equal => {}
588    }
589
590    push_direction(Direction::Right);
591    push_direction(Direction::Left);
592    push_direction(Direction::Down);
593    push_direction(Direction::Up);
594
595    ordered
596}
597
598fn step(point: Point, direction: Direction) -> Option<Point> {
599    match direction {
600        Direction::Left if point.x > 0 => Some(Point::new(point.x - 1, point.y)),
601        Direction::Right => Some(Point::new(point.x + 1, point.y)),
602        Direction::Up if point.y > 0 => Some(Point::new(point.x, point.y - 1)),
603        Direction::Down => Some(Point::new(point.x, point.y + 1)),
604        _ => None,
605    }
606}
607
608#[derive(Debug, Clone, Copy, PartialEq, Eq)]
609enum Direction {
610    Left,
611    Right,
612    Up,
613    Down,
614}
615
616#[cfg(test)]
617mod tests {
618    use crate::{
619        algorithms::astar::AStar,
620        grid::{Cell, Grid},
621        point::Point,
622        search::{Pathfinder, SearchRequest},
623    };
624
625    #[test]
626    fn finds_a_shortest_path_through_the_only_gap() {
627        let mut grid = Grid::new(5, 5).expect("grid dimensions are valid");
628        for y in 0..5 {
629            if y != 2 {
630                grid.set_cell(Point::new(2, y), Cell::Blocked)
631                    .expect("valid grid edit");
632            }
633        }
634
635        let astar = AStar;
636        let result = astar.search(
637            &grid,
638            SearchRequest::new(Point::new(0, 0), Point::new(4, 4)),
639        );
640
641        assert!(result.as_ref().expect("valid search request").is_found());
642        assert_eq!(
643            result.as_ref().expect("valid search request").cost(),
644            Some(8)
645        );
646
647        let path = result
648            .as_ref()
649            .expect("valid search request")
650            .path()
651            .expect("path should exist");
652        assert_eq!(path.start(), Point::new(0, 0));
653        assert_eq!(path.goal(), Point::new(4, 4));
654        assert!(path.steps().contains(&Point::new(2, 2)));
655    }
656
657    #[test]
658    fn reports_when_no_path_exists() {
659        let mut grid = Grid::new(3, 3).expect("grid dimensions are valid");
660        for x in 0..3 {
661            grid.set_cell(Point::new(x, 1), Cell::Blocked)
662                .expect("valid grid edit");
663        }
664
665        let astar = AStar;
666        let result = astar.search(
667            &grid,
668            SearchRequest::new(Point::new(0, 0), Point::new(2, 2)),
669        );
670
671        assert!(!result.as_ref().expect("valid search request").is_found());
672        assert_eq!(result.as_ref().expect("valid search request").cost(), None);
673        assert!(
674            result
675                .as_ref()
676                .expect("valid search request")
677                .stats()
678                .visited_nodes
679                > 0
680        );
681    }
682
683    #[test]
684    fn prefers_a_cheaper_weighted_detour() {
685        let mut grid = Grid::new(5, 3).expect("grid dimensions are valid");
686        assert_eq!(grid.set_traversal_cost(Point::new(1, 1), 5), Ok(()));
687        assert_eq!(grid.set_traversal_cost(Point::new(2, 1), 5), Ok(()));
688        assert_eq!(grid.set_traversal_cost(Point::new(3, 1), 5), Ok(()));
689
690        let astar = AStar;
691        let result = astar.search(
692            &grid,
693            SearchRequest::new(Point::new(0, 1), Point::new(4, 1)),
694        );
695
696        assert!(result.as_ref().expect("valid search request").is_found());
697        assert_eq!(
698            result.as_ref().expect("valid search request").cost(),
699            Some(6)
700        );
701        let path = result
702            .as_ref()
703            .expect("valid search request")
704            .path()
705            .expect("path should exist");
706        assert_eq!(path.cost(), 6);
707        assert!(
708            path.steps().contains(&Point::new(0, 0)) || path.steps().contains(&Point::new(0, 2))
709        );
710    }
711
712    #[test]
713    fn supports_maximum_single_edge_cost() {
714        let mut grid = Grid::new(2, 1).expect("grid dimensions are valid");
715        assert_eq!(
716            grid.set_traversal_cost(Point::new(1, 0), usize::MAX),
717            Ok(())
718        );
719
720        let result = AStar.search(
721            &grid,
722            SearchRequest::new(Point::new(0, 0), Point::new(1, 0)),
723        );
724
725        assert!(result.as_ref().expect("valid search request").is_found());
726        assert_eq!(
727            result.as_ref().expect("valid search request").cost(),
728            Some(usize::MAX)
729        );
730        assert_eq!(
731            result
732                .as_ref()
733                .expect("valid search request")
734                .path()
735                .expect("path should exist")
736                .cost(),
737            usize::MAX
738        );
739    }
740
741    #[test]
742    fn inspection_tracks_frontier_churn_metrics() {
743        let mut grid = Grid::new(5, 5).expect("grid dimensions are valid");
744        for y in 0..5 {
745            if y != 2 {
746                grid.set_cell(Point::new(2, y), Cell::Blocked)
747                    .expect("valid grid edit");
748            }
749        }
750
751        let inspection = AStar.inspect(
752            &grid,
753            SearchRequest::new(Point::new(0, 0), Point::new(4, 4)),
754        );
755
756        assert!(
757            inspection
758                .result
759                .as_ref()
760                .expect("valid search request")
761                .is_found()
762        );
763        assert_eq!(
764            inspection
765                .result
766                .as_ref()
767                .expect("valid search request")
768                .cost(),
769            Some(8)
770        );
771        assert!(inspection.diagnostics.frontier_pushes > 0);
772        assert!(inspection.diagnostics.frontier_pops > 0);
773        assert!(inspection.diagnostics.peak_frontier_len > 0);
774        assert!(inspection.diagnostics.distinct_estimated_total_costs_popped > 0);
775        assert!(inspection.diagnostics.max_equal_f_pop_run > 0);
776        assert!(inspection.diagnostics.goal_cost_plateau_pops > 0);
777        assert_eq!(
778            inspection.diagnostics.heuristic_slack_zero_pops
779                + inspection.diagnostics.heuristic_slack_1_to_4_pops
780                + inspection.diagnostics.heuristic_slack_5_to_16_pops
781                + inspection.diagnostics.heuristic_slack_17_plus_pops,
782            inspection
783                .result
784                .as_ref()
785                .expect("valid search request")
786                .stats()
787                .visited_nodes
788        );
789        assert!(
790            inspection.diagnostics.frontier_pushes > inspection.diagnostics.relaxations_accepted
791        );
792        assert!(
793            inspection.diagnostics.relaxation_attempts
794                >= inspection
795                    .diagnostics
796                    .relaxation_attempts_after_goal_discovery
797        );
798        assert!(
799            inspection.diagnostics.relaxations_accepted
800                >= inspection
801                    .diagnostics
802                    .relaxations_accepted_after_goal_discovery
803        );
804        assert_eq!(
805            inspection
806                .diagnostics
807                .relaxations_accepted_after_goal_discovery_first_touch
808                + inspection
809                    .diagnostics
810                    .relaxations_accepted_after_goal_discovery_improved,
811            inspection
812                .diagnostics
813                .relaxations_accepted_after_goal_discovery
814        );
815        assert_eq!(
816            inspection
817                .diagnostics
818                .relaxations_accepted_after_goal_discovery_first_touch_slack_zero
819                + inspection
820                    .diagnostics
821                    .relaxations_accepted_after_goal_discovery_first_touch_slack_1_to_4
822                + inspection
823                    .diagnostics
824                    .relaxations_accepted_after_goal_discovery_first_touch_slack_5_to_16
825                + inspection
826                    .diagnostics
827                    .relaxations_accepted_after_goal_discovery_first_touch_slack_17_plus,
828            inspection
829                .diagnostics
830                .relaxations_accepted_after_goal_discovery_first_touch
831        );
832        assert!(
833            inspection.diagnostics.frontier_pops
834                >= inspection
835                    .result
836                    .as_ref()
837                    .expect("valid search request")
838                    .stats()
839                    .visited_nodes
840                    + inspection.diagnostics.stale_pops_skipped
841        );
842        assert_eq!(
843            inspection.diagnostics.stale_pops_before_goal_discovery
844                + inspection.diagnostics.stale_pops_after_goal_discovery,
845            inspection.diagnostics.stale_pops_skipped
846        );
847        assert_eq!(
848            inspection.diagnostics.visited_nodes_before_goal_discovery
849                + inspection.diagnostics.visited_nodes_after_goal_discovery,
850            inspection
851                .result
852                .as_ref()
853                .expect("valid search request")
854                .stats()
855                .visited_nodes
856        );
857        assert_eq!(
858            inspection
859                .diagnostics
860                .visited_nodes_after_goal_discovery_below_goal_cost
861                + inspection
862                    .diagnostics
863                    .visited_nodes_after_goal_discovery_at_goal_cost
864                + inspection
865                    .diagnostics
866                    .visited_nodes_after_goal_discovery_above_goal_cost,
867            inspection.diagnostics.visited_nodes_after_goal_discovery
868        );
869        assert_eq!(
870            inspection
871                .diagnostics
872                .stale_pops_after_goal_discovery_below_goal_cost
873                + inspection
874                    .diagnostics
875                    .stale_pops_after_goal_discovery_at_goal_cost
876                + inspection
877                    .diagnostics
878                    .stale_pops_after_goal_discovery_above_goal_cost,
879            inspection.diagnostics.stale_pops_after_goal_discovery
880        );
881    }
882
883    #[test]
884    fn inspection_reports_weighted_path_cost() {
885        let mut grid = Grid::new(5, 3).expect("grid dimensions are valid");
886        assert_eq!(grid.set_traversal_cost(Point::new(1, 1), 5), Ok(()));
887        assert_eq!(grid.set_traversal_cost(Point::new(2, 1), 5), Ok(()));
888        assert_eq!(grid.set_traversal_cost(Point::new(3, 1), 5), Ok(()));
889
890        let inspection = AStar.inspect(
891            &grid,
892            SearchRequest::new(Point::new(0, 1), Point::new(4, 1)),
893        );
894
895        assert!(
896            inspection
897                .result
898                .as_ref()
899                .expect("valid search request")
900                .is_found()
901        );
902        assert_eq!(
903            inspection
904                .result
905                .as_ref()
906                .expect("valid search request")
907                .cost(),
908            Some(6)
909        );
910        assert_eq!(
911            inspection
912                .result
913                .as_ref()
914                .expect("valid search request")
915                .path()
916                .expect("path should exist")
917                .cost(),
918            6
919        );
920        assert!(inspection.diagnostics.max_heuristic_slack > 0);
921        assert_eq!(
922            inspection.diagnostics.goal_first_discovery_path_cost,
923            Some(6)
924        );
925        assert!(
926            inspection
927                .diagnostics
928                .goal_first_discovery_visited_nodes
929                .is_some()
930        );
931        assert!(
932            inspection
933                .diagnostics
934                .goal_first_discovery_frontier_len
935                .is_some()
936        );
937        assert_eq!(
938            inspection
939                .diagnostics
940                .goal_first_discovery_frontier_below_goal_cost
941                .zip(
942                    inspection
943                        .diagnostics
944                        .goal_first_discovery_frontier_at_goal_cost
945                )
946                .zip(
947                    inspection
948                        .diagnostics
949                        .goal_first_discovery_frontier_above_goal_cost
950                )
951                .map(|((below, at), above)| below + at + above),
952            inspection.diagnostics.goal_first_discovery_frontier_len
953        );
954        assert_eq!(
955            inspection.diagnostics.visited_nodes_before_goal_discovery
956                + inspection.diagnostics.visited_nodes_after_goal_discovery,
957            inspection
958                .result
959                .as_ref()
960                .expect("valid search request")
961                .stats()
962                .visited_nodes
963        );
964        assert!(inspection.diagnostics.visited_nodes_after_goal_discovery > 0);
965        assert!(
966            inspection
967                .diagnostics
968                .relaxation_attempts_after_goal_discovery
969                >= inspection
970                    .diagnostics
971                    .relaxations_accepted_after_goal_discovery
972        );
973        assert_eq!(
974            inspection
975                .diagnostics
976                .relaxations_accepted_after_goal_discovery_first_touch
977                + inspection
978                    .diagnostics
979                    .relaxations_accepted_after_goal_discovery_improved,
980            inspection
981                .diagnostics
982                .relaxations_accepted_after_goal_discovery
983        );
984        assert_eq!(
985            inspection
986                .diagnostics
987                .relaxations_accepted_after_goal_discovery_first_touch_slack_zero
988                + inspection
989                    .diagnostics
990                    .relaxations_accepted_after_goal_discovery_first_touch_slack_1_to_4
991                + inspection
992                    .diagnostics
993                    .relaxations_accepted_after_goal_discovery_first_touch_slack_5_to_16
994                + inspection
995                    .diagnostics
996                    .relaxations_accepted_after_goal_discovery_first_touch_slack_17_plus,
997            inspection
998                .diagnostics
999                .relaxations_accepted_after_goal_discovery_first_touch
1000        );
1001        assert_eq!(
1002            inspection
1003                .diagnostics
1004                .visited_nodes_after_goal_discovery_below_goal_cost
1005                + inspection
1006                    .diagnostics
1007                    .visited_nodes_after_goal_discovery_at_goal_cost
1008                + inspection
1009                    .diagnostics
1010                    .visited_nodes_after_goal_discovery_above_goal_cost,
1011            inspection.diagnostics.visited_nodes_after_goal_discovery
1012        );
1013        assert_eq!(
1014            inspection
1015                .diagnostics
1016                .stale_pops_after_goal_discovery_below_goal_cost
1017                + inspection
1018                    .diagnostics
1019                    .stale_pops_after_goal_discovery_at_goal_cost
1020                + inspection
1021                    .diagnostics
1022                    .stale_pops_after_goal_discovery_above_goal_cost,
1023            inspection.diagnostics.stale_pops_after_goal_discovery
1024        );
1025        assert!(
1026            inspection
1027                .diagnostics
1028                .heuristic_slack_sum_before_goal_discovery
1029                + inspection
1030                    .diagnostics
1031                    .heuristic_slack_sum_after_goal_discovery
1032                == inspection.diagnostics.heuristic_slack_sum
1033        );
1034    }
1035}