1use 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#[derive(Debug, Default, Clone, Copy)]
39pub struct AStar;
40
41#[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#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct AStarInspection {
95 pub result: SearchResult,
97 pub diagnostics: AStarDiagnostics,
99}
100
101impl AStar {
102 #[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}