Skip to main content

condor_grid/algorithms/
jump_point_search.rs

1//! Static-grid [`Pathfinder`]: online cardinal Jump Point Search.
2//!
3//! Each search scans its jump rays at query time and returns the standard
4//! invalid/found/no-path outcome. Jump-edge cost sums per-cell
5//! [`traversal_cost`](crate::Grid::traversal_cost) and uses a Manhattan heuristic.
6//! Prefer [`super::astar::AStar`] as the general weighted default, or
7//! [`super::jps_plus::JpsPlusBuilder`] when a static map justifies preprocessing.
8
9use std::{cmp::Ordering, collections::BinaryHeap};
10
11use crate::{
12    algorithms::jps_cardinal::{
13        Direction, PointKind, classify_point_kind, manhattan_distance, reconstruct_jump_path, step,
14    },
15    grid::Grid,
16    path::Path,
17    point::Point,
18    search::{Pathfinder, SearchRequest, SearchResult},
19};
20
21/// Online cardinal [`Pathfinder`]: Jump Point Search (4-way).
22///
23/// Scans jump rays at query time; weighted jump edges sum `traversal_cost` along the
24/// ray. Prefer on corridor-heavy maps; open rooms limit pruning. Prefer JPS+ when many
25/// queries share a static grid (precomputed jump table).
26#[derive(Debug, Default, Clone, Copy)]
27pub struct JumpPointSearch;
28
29/// Frontier and jump-scan counters from an instrumented [`JumpPointSearch::inspect`] run.
30///
31/// Bakeoff/inspect only; not part of public [`crate::SearchStats`].
32#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
33pub struct JumpPointSearchDiagnostics {
34    /// Open-set inserts (including re-pushes after decrease).
35    pub frontier_pushes: usize,
36    /// Open-set pops (including stale).
37    pub frontier_pops: usize,
38    /// Pops discarded because a better cost was already known.
39    pub stale_pops_skipped: usize,
40    /// Peak open-set size.
41    pub peak_frontier_len: usize,
42    /// Cardinal ray scan starts.
43    pub scan_attempts: usize,
44    /// Scans that hit a blocked neighbor immediately.
45    pub blocked_scans: usize,
46    /// Scans that produced at least one jump stop.
47    pub successful_scans: usize,
48    /// Candidate jump-edge relaxations considered.
49    pub relaxation_attempts: usize,
50    /// Relaxations that improved the known cost.
51    pub relaxations_accepted: usize,
52    /// Sum of jump lengths (cells) over successful scans.
53    pub total_jump_length: usize,
54    /// Longest single jump observed.
55    pub max_jump_length: usize,
56    /// Stops after a single-cell jump.
57    pub jump_length_1_stops: usize,
58    /// Stops after a short jump (2..=4 cells).
59    pub jump_length_2_to_4_stops: usize,
60    /// Stops after a long jump (≥5 cells).
61    pub jump_length_5_plus_stops: usize,
62    /// Rays that stopped because the goal was reached.
63    pub goal_stops: usize,
64    /// Rays that stopped at a branch cell.
65    pub branch_stops: usize,
66    /// Rays that stopped at an elbow turn.
67    pub elbow_turn_stops: usize,
68    /// Rays that stopped at a dead-end.
69    pub dead_end_stops: usize,
70}
71
72/// Search outcome paired with jump-pruning diagnostics.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct JumpPointSearchInspection {
75    /// Standard found/no-path or invalid/budget result.
76    pub result: SearchResult,
77    /// Jump-scan tallies for this instrumented run.
78    pub diagnostics: JumpPointSearchDiagnostics,
79}
80
81impl JumpPointSearch {
82    /// Runs an instrumented search. Invalid endpoints are reported through
83    /// [`JumpPointSearchInspection::result`] with the same errors as
84    /// [`Pathfinder::search`].
85    #[must_use]
86    pub fn inspect(&self, grid: &Grid, request: SearchRequest) -> JumpPointSearchInspection {
87        if let Err(error) = crate::search::validate_request(grid, request) {
88            return JumpPointSearchInspection {
89                result: Err(error),
90                diagnostics: JumpPointSearchDiagnostics::default(),
91            };
92        }
93        let execution = search_impl::<true>(grid, request);
94        JumpPointSearchInspection {
95            result: execution.result,
96            diagnostics: execution.diagnostics,
97        }
98    }
99}
100
101impl Pathfinder for JumpPointSearch {
102    fn name(&self) -> &'static str {
103        "jump-point-search"
104    }
105
106    fn search(&self, grid: &Grid, request: SearchRequest) -> SearchResult {
107        crate::search::validate_request(grid, request)?;
108        search_impl::<false>(grid, request).result
109    }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
113struct SearchExecution {
114    result: SearchResult,
115    diagnostics: JumpPointSearchDiagnostics,
116}
117
118fn search_impl<const TRACK_DIAGNOSTICS: bool>(
119    grid: &Grid,
120    request: SearchRequest,
121) -> SearchExecution {
122    let mut diagnostics = JumpPointSearchDiagnostics::default();
123
124    let Some(start_index) = grid.index_of(request.start) else {
125        return SearchExecution {
126            result: crate::search::not_found(0),
127            diagnostics,
128        };
129    };
130    let Some(goal_index) = grid.index_of(request.goal) else {
131        return SearchExecution {
132            result: crate::search::not_found(0),
133            diagnostics,
134        };
135    };
136
137    if !grid.is_walkable(request.start) || !grid.is_walkable(request.goal) {
138        return SearchExecution {
139            result: crate::search::not_found(0),
140            diagnostics,
141        };
142    }
143
144    if !grid.is_reachable(request.start, request.goal) {
145        return SearchExecution {
146            result: crate::search::not_found(0),
147            diagnostics,
148        };
149    }
150
151    if request.start == request.goal {
152        return SearchExecution {
153            result: crate::search::found(
154                Path::from_steps(vec![request.start]).expect("path contains at least one point"),
155                1,
156            ),
157            diagnostics,
158        };
159    }
160
161    let initial_heuristic = manhattan_distance(request.start, request.goal);
162    let mut frontier = BinaryHeap::from([FrontierEntry {
163        estimated_total_cost: initial_heuristic,
164        heuristic_cost: initial_heuristic,
165        cost_so_far: 0,
166        index: start_index,
167    }]);
168    let mut best_costs = vec![None; grid.cell_count()];
169    let mut parents = vec![None; grid.cell_count()];
170    let mut visited_nodes = 0usize;
171    let watch = crate::search::BudgetWatch::start(request.budget);
172
173    best_costs[start_index] = Some(0);
174
175    if TRACK_DIAGNOSTICS {
176        diagnostics.frontier_pushes = 1;
177        diagnostics.peak_frontier_len = 1;
178    }
179
180    while let Some(entry) = frontier.pop() {
181        if TRACK_DIAGNOSTICS {
182            diagnostics.frontier_pops += 1;
183        }
184
185        if best_costs[entry.index] != Some(entry.cost_so_far) {
186            if TRACK_DIAGNOSTICS {
187                diagnostics.stale_pops_skipped += 1;
188            }
189            continue;
190        }
191
192        visited_nodes += 1;
193        if entry.index == goal_index {
194            break;
195        }
196
197        if let Err(reason) = watch.check(visited_nodes) {
198            return SearchExecution {
199                result: Err(crate::search::budget_error(reason)),
200                diagnostics,
201            };
202        }
203
204        let current = grid.point_from_index(entry.index);
205        for direction in Direction::ALL {
206            if TRACK_DIAGNOSTICS {
207                diagnostics.scan_attempts += 1;
208            }
209
210            let Some(scan_stop) =
211                jump_in_direction(grid, current, entry.index, direction, goal_index)
212            else {
213                if TRACK_DIAGNOSTICS {
214                    diagnostics.blocked_scans += 1;
215                }
216                continue;
217            };
218
219            if TRACK_DIAGNOSTICS {
220                diagnostics.successful_scans += 1;
221                diagnostics.total_jump_length += scan_stop.edge_cost;
222                diagnostics.max_jump_length = diagnostics.max_jump_length.max(scan_stop.edge_cost);
223                match scan_stop.edge_cost {
224                    0 => {}
225                    1 => diagnostics.jump_length_1_stops += 1,
226                    2..=4 => diagnostics.jump_length_2_to_4_stops += 1,
227                    _ => diagnostics.jump_length_5_plus_stops += 1,
228                }
229
230                match scan_stop.reason {
231                    StopReason::Goal => diagnostics.goal_stops += 1,
232                    StopReason::Branch => diagnostics.branch_stops += 1,
233                    StopReason::ElbowTurn => diagnostics.elbow_turn_stops += 1,
234                    StopReason::DeadEnd => diagnostics.dead_end_stops += 1,
235                }
236                diagnostics.relaxation_attempts += 1;
237            }
238
239            let Some(next_cost) = entry.cost_so_far.checked_add(scan_stop.edge_weight) else {
240                continue;
241            };
242            if best_costs[scan_stop.target_index].is_some_and(|best_cost| next_cost >= best_cost) {
243                continue;
244            }
245
246            best_costs[scan_stop.target_index] = Some(next_cost);
247            parents[scan_stop.target_index] = Some(entry.index);
248            let target = grid.point_from_index(scan_stop.target_index);
249            let heuristic_cost = manhattan_distance(target, request.goal);
250            frontier.push(FrontierEntry {
251                estimated_total_cost: next_cost.saturating_add(heuristic_cost),
252                heuristic_cost,
253                cost_so_far: next_cost,
254                index: scan_stop.target_index,
255            });
256
257            if TRACK_DIAGNOSTICS {
258                diagnostics.relaxations_accepted += 1;
259                diagnostics.frontier_pushes += 1;
260                diagnostics.peak_frontier_len = diagnostics.peak_frontier_len.max(frontier.len());
261            }
262        }
263    }
264
265    let result = if let Some(goal_cost) = best_costs[goal_index] {
266        crate::search::found(
267            reconstruct_jump_path(grid, &parents, start_index, goal_index, goal_cost),
268            visited_nodes,
269        )
270    } else {
271        crate::search::not_found(visited_nodes)
272    };
273
274    SearchExecution {
275        result,
276        diagnostics,
277    }
278}
279
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281struct ScanStop {
282    target_index: usize,
283    edge_cost: usize,
284    edge_weight: usize,
285    reason: StopReason,
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289enum StopReason {
290    Goal,
291    Branch,
292    ElbowTurn,
293    DeadEnd,
294}
295
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297struct FrontierEntry {
298    estimated_total_cost: usize,
299    heuristic_cost: usize,
300    cost_so_far: usize,
301    index: usize,
302}
303
304impl Ord for FrontierEntry {
305    fn cmp(&self, other: &Self) -> Ordering {
306        other
307            .estimated_total_cost
308            .cmp(&self.estimated_total_cost)
309            .then_with(|| other.heuristic_cost.cmp(&self.heuristic_cost))
310            .then_with(|| self.cost_so_far.cmp(&other.cost_so_far))
311            .then_with(|| other.index.cmp(&self.index))
312    }
313}
314
315impl PartialOrd for FrontierEntry {
316    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
317        Some(self.cmp(other))
318    }
319}
320
321fn jump_in_direction(
322    grid: &Grid,
323    start: Point,
324    start_index: usize,
325    direction: Direction,
326    goal_index: usize,
327) -> Option<ScanStop> {
328    let mut point = start;
329    let mut index = start_index;
330    let mut edge_cost = 0usize;
331    let mut edge_weight = 0usize;
332
333    loop {
334        let (next_point, next_index) = step(grid, point, index, direction)?;
335        point = next_point;
336        index = next_index;
337        edge_cost = edge_cost.checked_add(1)?;
338        edge_weight = edge_weight.checked_add(grid.traversal_cost(point).unwrap_or(1))?;
339
340        if index == goal_index {
341            return Some(ScanStop {
342                target_index: index,
343                edge_cost,
344                edge_weight,
345                reason: StopReason::Goal,
346            });
347        }
348
349        let point_kind = classify_point_kind(grid, point);
350        if point_kind != PointKind::StraightCorridor {
351            return Some(ScanStop {
352                target_index: index,
353                edge_cost,
354                edge_weight,
355                reason: match point_kind {
356                    PointKind::StraightCorridor => unreachable!("straight corridor handled above"),
357                    PointKind::Branch => StopReason::Branch,
358                    PointKind::ElbowTurn => StopReason::ElbowTurn,
359                    PointKind::DeadEnd => StopReason::DeadEnd,
360                },
361            });
362        }
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use crate::{
369        algorithms::jps_cardinal::{PointKind, classify_point_kind},
370        algorithms::jump_point_search::JumpPointSearch,
371        grid::{Cell, Grid},
372        point::Point,
373        search::{Pathfinder, SearchRequest},
374    };
375
376    #[test]
377    fn finds_a_shortest_path_through_the_only_gap() {
378        let mut grid = Grid::new(5, 5).expect("grid dimensions are valid");
379        for y in 0..5 {
380            if y != 2 {
381                grid.set_cell(Point::new(2, y), Cell::Blocked)
382                    .expect("valid grid edit");
383            }
384        }
385
386        let jps = JumpPointSearch;
387        let result = jps.search(
388            &grid,
389            SearchRequest::new(Point::new(0, 0), Point::new(4, 4)),
390        );
391
392        assert!(result.as_ref().expect("valid search request").is_found());
393        assert_eq!(
394            result.as_ref().expect("valid search request").cost(),
395            Some(8)
396        );
397        let path = result
398            .as_ref()
399            .expect("valid search request")
400            .path()
401            .expect("path should exist");
402        assert!(path.steps().contains(&Point::new(2, 2)));
403    }
404
405    #[test]
406    fn reports_when_no_path_exists() {
407        let mut grid = Grid::new(3, 3).expect("grid dimensions are valid");
408        for x in 0..3 {
409            grid.set_cell(Point::new(x, 1), Cell::Blocked)
410                .expect("valid grid edit");
411        }
412
413        let jps = JumpPointSearch;
414        let result = jps.search(
415            &grid,
416            SearchRequest::new(Point::new(0, 0), Point::new(2, 2)),
417        );
418
419        assert!(!result.as_ref().expect("valid search request").is_found());
420        assert_eq!(result.as_ref().expect("valid search request").cost(), None);
421        assert!(
422            result
423                .as_ref()
424                .expect("valid search request")
425                .stats()
426                .visited_nodes
427                > 0
428        );
429    }
430
431    #[test]
432    fn skips_a_straight_corridor() {
433        let grid = Grid::new(8, 1).expect("grid dimensions are valid");
434        let jps = JumpPointSearch;
435        let result = jps.search(
436            &grid,
437            SearchRequest::new(Point::new(0, 0), Point::new(7, 0)),
438        );
439
440        assert!(result.as_ref().expect("valid search request").is_found());
441        assert_eq!(
442            result.as_ref().expect("valid search request").cost(),
443            Some(7)
444        );
445        assert_eq!(
446            result
447                .as_ref()
448                .expect("valid search request")
449                .stats()
450                .visited_nodes,
451            2
452        );
453        let path = result
454            .as_ref()
455            .expect("valid search request")
456            .path()
457            .expect("path should exist");
458        assert_eq!(path.len(), 8);
459    }
460
461    #[test]
462    fn charges_per_cell_traversal_cost_matching_dijkstra() {
463        let mut grid = Grid::new(8, 1).expect("grid dimensions are valid");
464        assert_eq!(grid.set_traversal_cost(Point::new(4, 0), 5), Ok(()));
465
466        let request = SearchRequest::new(Point::new(0, 0), Point::new(7, 0));
467        let jps_cost = JumpPointSearch
468            .search(&grid, request)
469            .as_ref()
470            .expect("valid search request")
471            .cost();
472        let dijkstra_cost = crate::Dijkstra
473            .search(&grid, request)
474            .as_ref()
475            .expect("valid search request")
476            .cost();
477
478        assert_eq!(jps_cost, Some(11));
479        assert_eq!(jps_cost, dijkstra_cost);
480    }
481
482    #[test]
483    fn supports_maximum_single_edge_cost() {
484        let mut grid = Grid::new(2, 1).expect("grid dimensions are valid");
485        assert_eq!(
486            grid.set_traversal_cost(Point::new(1, 0), usize::MAX),
487            Ok(())
488        );
489
490        let result = JumpPointSearch.search(
491            &grid,
492            SearchRequest::new(Point::new(0, 0), Point::new(1, 0)),
493        );
494
495        assert!(result.as_ref().expect("valid search request").is_found());
496        assert_eq!(
497            result.as_ref().expect("valid search request").cost(),
498            Some(usize::MAX)
499        );
500        assert_eq!(
501            result
502                .as_ref()
503                .expect("valid search request")
504                .path()
505                .expect("path should exist")
506                .cost(),
507            usize::MAX
508        );
509    }
510
511    #[test]
512    fn skips_overflowing_jump_costs_matching_dijkstra() {
513        let mut grid = Grid::new(3, 1).expect("grid dimensions are valid");
514        assert_eq!(
515            grid.set_traversal_cost(Point::new(1, 0), usize::MAX),
516            Ok(())
517        );
518
519        let request = SearchRequest::new(Point::new(0, 0), Point::new(2, 0));
520        let jps = JumpPointSearch.search(&grid, request);
521        let dijkstra = crate::Dijkstra.search(&grid, request);
522
523        assert!(!jps.as_ref().expect("valid search request").is_found());
524        assert_eq!(jps.as_ref().expect("valid search request").cost(), None);
525        assert_eq!(
526            jps.as_ref().expect("valid search request").cost(),
527            dijkstra.as_ref().expect("valid search request").cost()
528        );
529    }
530
531    #[test]
532    fn stops_at_goal_inside_a_straight_corridor() {
533        let grid = Grid::new(8, 1).expect("grid dimensions are valid");
534        let jps = JumpPointSearch;
535        let result = jps.search(
536            &grid,
537            SearchRequest::new(Point::new(0, 0), Point::new(4, 0)),
538        );
539
540        assert!(result.as_ref().expect("valid search request").is_found());
541        assert_eq!(
542            result.as_ref().expect("valid search request").cost(),
543            Some(4)
544        );
545        assert_eq!(
546            result
547                .as_ref()
548                .expect("valid search request")
549                .stats()
550                .visited_nodes,
551            2
552        );
553        let path = result
554            .as_ref()
555            .expect("valid search request")
556            .path()
557            .expect("path should exist");
558        assert_eq!(path.len(), 5);
559        assert_eq!(path.goal(), Point::new(4, 0));
560    }
561
562    #[test]
563    fn finds_a_shortest_path_inside_an_open_room() {
564        let grid = Grid::new(5, 5).expect("grid dimensions are valid");
565        let jps = JumpPointSearch;
566        let result = jps.search(
567            &grid,
568            SearchRequest::new(Point::new(0, 0), Point::new(2, 2)),
569        );
570
571        assert!(result.as_ref().expect("valid search request").is_found());
572        assert_eq!(
573            result.as_ref().expect("valid search request").cost(),
574            Some(4)
575        );
576        let path = result
577            .as_ref()
578            .expect("valid search request")
579            .path()
580            .expect("path should exist");
581        assert_eq!(path.start(), Point::new(0, 0));
582        assert_eq!(path.goal(), Point::new(2, 2));
583    }
584
585    #[test]
586    fn turns_at_a_t_junction_when_goal_leaves_the_corridor() {
587        let mut grid = Grid::new(5, 3).expect("grid dimensions are valid");
588        for y in 0..3 {
589            for x in 0..5 {
590                if y != 1 && x != 2 {
591                    grid.set_cell(Point::new(x, y), Cell::Blocked)
592                        .expect("valid grid edit");
593                }
594            }
595        }
596
597        let jps = JumpPointSearch;
598        let result = jps.search(
599            &grid,
600            SearchRequest::new(Point::new(0, 1), Point::new(2, 0)),
601        );
602
603        assert!(result.as_ref().expect("valid search request").is_found());
604        assert_eq!(
605            result.as_ref().expect("valid search request").cost(),
606            Some(3)
607        );
608        let path = result
609            .as_ref()
610            .expect("valid search request")
611            .path()
612            .expect("path should exist");
613        assert!(path.steps().contains(&Point::new(2, 1)));
614        assert_eq!(path.goal(), Point::new(2, 0));
615    }
616
617    #[test]
618    fn turns_at_an_elbow_corridor() {
619        let mut grid = Grid::new(5, 5).expect("grid dimensions are valid");
620        for y in 0..5 {
621            for x in 0..5 {
622                if x != 0 && y != 4 {
623                    grid.set_cell(Point::new(x, y), Cell::Blocked)
624                        .expect("valid grid edit");
625                }
626            }
627        }
628
629        let jps = JumpPointSearch;
630        let result = jps.search(
631            &grid,
632            SearchRequest::new(Point::new(0, 0), Point::new(4, 4)),
633        );
634
635        assert!(result.as_ref().expect("valid search request").is_found());
636        assert_eq!(
637            result.as_ref().expect("valid search request").cost(),
638            Some(8)
639        );
640        let path = result
641            .as_ref()
642            .expect("valid search request")
643            .path()
644            .expect("path should exist");
645        assert_eq!(path.start(), Point::new(0, 0));
646        assert_eq!(path.goal(), Point::new(4, 4));
647        assert!(path.steps().contains(&Point::new(0, 4)));
648    }
649
650    #[test]
651    fn inspection_tracks_branch_and_jump_length_metrics() {
652        let grid = Grid::new(8, 1).expect("grid dimensions are valid");
653
654        let inspection = JumpPointSearch.inspect(
655            &grid,
656            SearchRequest::new(Point::new(0, 0), Point::new(7, 0)),
657        );
658
659        assert!(
660            inspection
661                .result
662                .as_ref()
663                .expect("valid search request")
664                .is_found()
665        );
666        assert_eq!(
667            inspection
668                .result
669                .as_ref()
670                .expect("valid search request")
671                .cost(),
672            Some(7)
673        );
674        assert_eq!(inspection.diagnostics.frontier_pushes, 2);
675        assert_eq!(inspection.diagnostics.frontier_pops, 2);
676        assert_eq!(inspection.diagnostics.successful_scans, 1);
677        assert_eq!(inspection.diagnostics.goal_stops, 1);
678        assert_eq!(inspection.diagnostics.jump_length_5_plus_stops, 1);
679        assert_eq!(inspection.diagnostics.max_jump_length, 7);
680    }
681
682    #[test]
683    fn classifies_open_room_cells_as_branches() {
684        let grid = Grid::new(3, 3).expect("grid dimensions are valid");
685
686        assert_eq!(
687            classify_point_kind(&grid, Point::new(1, 1)),
688            PointKind::Branch
689        );
690    }
691}