Skip to main content

rust_robotics_planning/
jps.rs

1#![allow(clippy::collapsible_if)]
2
3//! Jump Point Search (JPS) path planning algorithm
4//!
5//! JPS is an optimization of A* for uniform-cost grids that reduces the
6//! number of nodes expanded by identifying "jump points" - nodes that
7//! have forced neighbors or are the goal.
8//!
9//! Reference: Harabor, D., & Grastien, A. (2011). Online Graph Pruning for
10//! Pathfinding on Grid Maps.
11
12use std::cmp::Ordering;
13use std::collections::{BinaryHeap, HashMap};
14
15use crate::grid::{GridMap, Node};
16use rust_robotics_core::{Obstacles, Path2D, PathPlanner, Point2D, RoboticsError, RoboticsResult};
17
18/// Configuration for JPS planner
19#[derive(Debug, Clone)]
20pub struct JPSConfig {
21    /// Grid resolution in meters
22    pub resolution: f64,
23    /// Robot radius for obstacle inflation
24    pub robot_radius: f64,
25    /// Heuristic weight (1.0 = optimal, >1.0 = faster but suboptimal)
26    pub heuristic_weight: f64,
27}
28
29impl Default for JPSConfig {
30    fn default() -> Self {
31        Self {
32            resolution: 1.0,
33            robot_radius: 0.5,
34            heuristic_weight: 1.0,
35        }
36    }
37}
38
39impl JPSConfig {
40    pub fn validate(&self) -> RoboticsResult<()> {
41        if !self.resolution.is_finite() || self.resolution <= 0.0 {
42            return Err(RoboticsError::InvalidParameter(format!(
43                "resolution must be positive and finite, got {}",
44                self.resolution
45            )));
46        }
47        if !self.robot_radius.is_finite() || self.robot_radius < 0.0 {
48            return Err(RoboticsError::InvalidParameter(format!(
49                "robot_radius must be non-negative and finite, got {}",
50                self.robot_radius
51            )));
52        }
53        if !self.heuristic_weight.is_finite() || self.heuristic_weight <= 0.0 {
54            return Err(RoboticsError::InvalidParameter(format!(
55                "heuristic_weight must be positive and finite, got {}",
56                self.heuristic_weight
57            )));
58        }
59
60        Ok(())
61    }
62}
63
64/// Why the JPS planner had to fall back to grid A*.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum JPSFallbackReason {
67    /// The reconstructed JPS path was invalid under the shared grid semantics.
68    InvalidJumpPath,
69    /// The reconstructed JPS path was valid but longer than the shared grid-optimal reference.
70    SuboptimalJumpPath,
71    /// The jump search failed to reach the goal, so the planner retried with grid A*.
72    SearchExhausted,
73}
74
75impl JPSFallbackReason {
76    pub fn as_str(self) -> &'static str {
77        match self {
78            Self::InvalidJumpPath => "invalid_jump_path",
79            Self::SuboptimalJumpPath => "suboptimal_jump_path",
80            Self::SearchExhausted => "search_exhausted",
81        }
82    }
83}
84
85/// More specific classification for `InvalidJumpPath`.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
87pub enum JPSInvalidJumpPathDetail {
88    /// The reconstructed jump path contains at least one invalid grid step.
89    InvalidStepSequence,
90    /// The reconstructed jump path is geometrically valid but its length no longer matches the
91    /// search cost accumulated on jump points.
92    CostMismatch,
93    /// The reconstructed jump path has both invalid steps and a cost mismatch.
94    InvalidStepSequenceAndCostMismatch,
95}
96
97impl JPSInvalidJumpPathDetail {
98    pub fn as_str(self) -> &'static str {
99        match self {
100            Self::InvalidStepSequence => "invalid_step_sequence",
101            Self::CostMismatch => "cost_mismatch",
102            Self::InvalidStepSequenceAndCostMismatch => "invalid_step_and_cost_mismatch",
103        }
104    }
105}
106
107/// Additional metrics attached to `cost_mismatch` fallback cases.
108#[derive(Debug, Clone, PartialEq)]
109pub struct JPSDetourSegment {
110    pub from_x: i32,
111    pub from_y: i32,
112    pub to_x: i32,
113    pub to_y: i32,
114    pub expected_length: f64,
115    pub reconstructed_length: f64,
116}
117
118/// Additional metrics attached to `cost_mismatch` fallback cases.
119#[derive(Debug, Clone, PartialEq)]
120pub struct JPSCostMismatchMetrics {
121    pub search_cost: f64,
122    pub reconstructed_path_length: f64,
123    pub delta: f64,
124    pub detour_segment_count: usize,
125    pub detour_segments: Vec<JPSDetourSegment>,
126}
127
128/// Extra execution diagnostics for one JPS planning query.
129#[derive(Debug, Clone, PartialEq)]
130pub struct JPSPlanDiagnostics {
131    pub used_fallback: bool,
132    pub fallback_reason: Option<JPSFallbackReason>,
133    pub invalid_jump_path_detail: Option<JPSInvalidJumpPathDetail>,
134    pub cost_mismatch_metrics: Option<JPSCostMismatchMetrics>,
135}
136
137/// Planning result with path plus JPS-specific diagnostics.
138#[derive(Debug, Clone)]
139pub struct JPSPlanResult {
140    pub path: Path2D,
141    pub diagnostics: JPSPlanDiagnostics,
142}
143
144/// Direction for movement in JPS
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
146struct Direction {
147    dx: i32,
148    dy: i32,
149}
150
151impl Direction {
152    fn new(dx: i32, dy: i32) -> Self {
153        Self { dx, dy }
154    }
155
156    fn is_diagonal(&self) -> bool {
157        self.dx != 0 && self.dy != 0
158    }
159
160    fn is_cardinal(&self) -> bool {
161        !self.is_diagonal()
162    }
163}
164
165/// Node with priority for JPS open set (min-heap)
166#[derive(Debug)]
167struct PriorityNode {
168    x: i32,
169    y: i32,
170    cost: f64,
171    priority: f64,
172    index: usize,
173}
174
175impl Eq for PriorityNode {}
176
177impl PartialEq for PriorityNode {
178    fn eq(&self, other: &Self) -> bool {
179        self.priority == other.priority
180    }
181}
182
183impl Ord for PriorityNode {
184    fn cmp(&self, other: &Self) -> Ordering {
185        // Reverse ordering for min-heap behavior
186        other
187            .priority
188            .partial_cmp(&self.priority)
189            .unwrap_or(Ordering::Equal)
190    }
191}
192
193impl PartialOrd for PriorityNode {
194    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
195        Some(self.cmp(other))
196    }
197}
198
199/// Jump Point Search path planner
200pub struct JPSPlanner {
201    grid_map: GridMap,
202    config: JPSConfig,
203}
204
205impl JPSPlanner {
206    fn state_key(&self, x: i32, y: i32, dir: Option<Direction>) -> (i32, Option<Direction>) {
207        (self.grid_map.calc_index(x, y), dir)
208    }
209
210    /// Create a new JPS planner with obstacle positions
211    pub fn new(ox: &[f64], oy: &[f64], config: JPSConfig) -> Self {
212        Self::try_new(ox, oy, config).expect(
213            "invalid JPS planner input: obstacle list must be non-empty and valid, and config values must be positive/finite",
214        )
215    }
216
217    /// Create a validated JPS planner with obstacle positions
218    pub fn try_new(ox: &[f64], oy: &[f64], config: JPSConfig) -> RoboticsResult<Self> {
219        config.validate()?;
220        let grid_map = GridMap::try_new(ox, oy, config.resolution, config.robot_radius)?;
221        Ok(JPSPlanner { grid_map, config })
222    }
223
224    /// Create from obstacle x/y vectors with default config
225    pub fn from_obstacles(ox: &[f64], oy: &[f64], resolution: f64, robot_radius: f64) -> Self {
226        let config = JPSConfig {
227            resolution,
228            robot_radius,
229            ..Default::default()
230        };
231        Self::new(ox, oy, config)
232    }
233
234    /// Create a validated JPS planner from typed obstacle points
235    pub fn from_obstacle_points(obstacles: &Obstacles, config: JPSConfig) -> RoboticsResult<Self> {
236        config.validate()?;
237        let grid_map = GridMap::from_obstacles(obstacles, config.resolution, config.robot_radius)?;
238        Ok(JPSPlanner { grid_map, config })
239    }
240
241    /// Plan a path returning (rx, ry) vectors (legacy interface)
242    #[deprecated(note = "use plan() or plan_xy() instead")]
243    pub fn planning(&self, sx: f64, sy: f64, gx: f64, gy: f64) -> Option<(Vec<f64>, Vec<f64>)> {
244        match self.plan_xy(sx, sy, gx, gy) {
245            Ok(path) => Some((path.x_coords(), path.y_coords())),
246            Err(_) => None,
247        }
248    }
249
250    /// Plan a path without requiring the PathPlanner trait in scope
251    pub fn plan(&self, start: Point2D, goal: Point2D) -> RoboticsResult<Path2D> {
252        self.plan_with_diagnostics(start, goal)
253            .map(|result| result.path)
254    }
255
256    /// Plan a path from raw coordinates without requiring the PathPlanner trait in scope
257    pub fn plan_xy(&self, sx: f64, sy: f64, gx: f64, gy: f64) -> RoboticsResult<Path2D> {
258        self.plan_with_diagnostics(Point2D::new(sx, sy), Point2D::new(gx, gy))
259            .map(|result| result.path)
260    }
261
262    /// Plan a path and report whether the query used the grid A* fallback.
263    pub fn plan_with_diagnostics(
264        &self,
265        start: Point2D,
266        goal: Point2D,
267    ) -> RoboticsResult<JPSPlanResult> {
268        self.plan_impl(start, goal)
269    }
270
271    /// Get reference to the grid map
272    pub fn grid_map(&self) -> &GridMap {
273        &self.grid_map
274    }
275
276    fn calc_heuristic(&self, n1_x: i32, n1_y: i32, n2_x: i32, n2_y: i32) -> f64 {
277        // Octile distance heuristic for 8-connected grid
278        let dx = (n1_x - n2_x).abs() as f64;
279        let dy = (n1_y - n2_y).abs() as f64;
280        let d_min = dx.min(dy);
281        let d_max = dx.max(dy);
282        self.config.heuristic_weight * (d_min * std::f64::consts::SQRT_2 + (d_max - d_min))
283    }
284
285    /// Check if a position has forced neighbors in the given direction
286    fn has_forced_neighbor(&self, x: i32, y: i32, dir: Direction) -> bool {
287        if dir.is_cardinal() {
288            // For cardinal directions, check perpendicular neighbors
289            if dir.dx != 0 {
290                // Moving horizontally
291                let blocked_up = !self.grid_map.is_valid(x - dir.dx, y + 1);
292                let open_up = self.grid_map.is_valid(x, y + 1);
293                let blocked_down = !self.grid_map.is_valid(x - dir.dx, y - 1);
294                let open_down = self.grid_map.is_valid(x, y - 1);
295                (blocked_up && open_up) || (blocked_down && open_down)
296            } else {
297                // Moving vertically
298                let blocked_right = !self.grid_map.is_valid(x + 1, y - dir.dy);
299                let open_right = self.grid_map.is_valid(x + 1, y);
300                let blocked_left = !self.grid_map.is_valid(x - 1, y - dir.dy);
301                let open_left = self.grid_map.is_valid(x - 1, y);
302                (blocked_right && open_right) || (blocked_left && open_left)
303            }
304        } else {
305            false
306        }
307    }
308
309    /// Jump in a given direction from (x, y) until we find a jump point or hit an obstacle
310    fn jump(&self, x: i32, y: i32, dir: Direction, goal_x: i32, goal_y: i32) -> Option<(i32, i32)> {
311        let nx = x + dir.dx;
312        let ny = y + dir.dy;
313
314        if !self.grid_map.is_valid_step(x, y, nx, ny) {
315            return None;
316        }
317
318        if nx == goal_x && ny == goal_y {
319            return Some((nx, ny));
320        }
321
322        if self.has_forced_neighbor(nx, ny, dir) {
323            return Some((nx, ny));
324        }
325
326        if dir.is_diagonal() {
327            if self
328                .jump(nx, ny, Direction::new(dir.dx, 0), goal_x, goal_y)
329                .is_some()
330            {
331                return Some((nx, ny));
332            }
333            if self
334                .jump(nx, ny, Direction::new(0, dir.dy), goal_x, goal_y)
335                .is_some()
336            {
337                return Some((nx, ny));
338            }
339        }
340
341        self.jump(nx, ny, dir, goal_x, goal_y)
342    }
343
344    fn get_successors(
345        &self,
346        x: i32,
347        y: i32,
348        parent_dir: Option<Direction>,
349        goal_x: i32,
350        goal_y: i32,
351    ) -> Vec<(i32, i32, Direction)> {
352        let mut successors = Vec::new();
353        let directions = self.get_neighbors(x, y, parent_dir);
354
355        for dir in directions {
356            if let Some((jx, jy)) = self.jump(x, y, dir, goal_x, goal_y) {
357                successors.push((jx, jy, dir));
358            }
359        }
360
361        successors
362    }
363
364    fn get_neighbors(&self, x: i32, y: i32, parent_dir: Option<Direction>) -> Vec<Direction> {
365        match parent_dir {
366            None => {
367                let mut dirs = Vec::new();
368                for dx in -1..=1 {
369                    for dy in -1..=1 {
370                        if dx == 0 && dy == 0 {
371                            continue;
372                        }
373                        let dir = Direction::new(dx, dy);
374                        if self.grid_map.is_valid_step(x, y, x + dx, y + dy) {
375                            dirs.push(dir);
376                        }
377                    }
378                }
379                dirs
380            }
381            Some(dir) => {
382                let mut dirs = Vec::new();
383
384                if dir.is_diagonal() {
385                    if self.grid_map.is_valid_step(x, y, x + dir.dx, y + dir.dy) {
386                        dirs.push(dir);
387                    }
388                    if self.grid_map.is_valid_step(x, y, x + dir.dx, y) {
389                        dirs.push(Direction::new(dir.dx, 0));
390                    }
391                    if self.grid_map.is_valid_step(x, y, x, y + dir.dy) {
392                        dirs.push(Direction::new(0, dir.dy));
393                    }
394
395                    if !self.grid_map.is_valid(x - dir.dx, y) {
396                        if self.grid_map.is_valid_step(x, y, x - dir.dx, y + dir.dy) {
397                            dirs.push(Direction::new(-dir.dx, dir.dy));
398                        }
399                    }
400                    if !self.grid_map.is_valid(x, y - dir.dy) {
401                        if self.grid_map.is_valid_step(x, y, x + dir.dx, y - dir.dy) {
402                            dirs.push(Direction::new(dir.dx, -dir.dy));
403                        }
404                    }
405                } else if dir.dx != 0 {
406                    if self.grid_map.is_valid_step(x, y, x + dir.dx, y) {
407                        dirs.push(dir);
408                    }
409                    let blocked_lower = !self.grid_map.is_valid(x - dir.dx, y + 1);
410                    if blocked_lower {
411                        if self.grid_map.is_valid_step(x, y, x + dir.dx, y + 1) {
412                            dirs.push(Direction::new(dir.dx, 1));
413                        }
414                        if self.grid_map.is_valid_step(x, y, x, y + 1) {
415                            dirs.push(Direction::new(0, 1));
416                        }
417                    }
418                    let blocked_upper = !self.grid_map.is_valid(x - dir.dx, y - 1);
419                    if blocked_upper {
420                        if self.grid_map.is_valid_step(x, y, x + dir.dx, y - 1) {
421                            dirs.push(Direction::new(dir.dx, -1));
422                        }
423                        if self.grid_map.is_valid_step(x, y, x, y - 1) {
424                            dirs.push(Direction::new(0, -1));
425                        }
426                    }
427                } else {
428                    if self.grid_map.is_valid_step(x, y, x, y + dir.dy) {
429                        dirs.push(dir);
430                    }
431                    let blocked_right = !self.grid_map.is_valid(x + 1, y - dir.dy);
432                    if blocked_right {
433                        if self.grid_map.is_valid_step(x, y, x + 1, y + dir.dy) {
434                            dirs.push(Direction::new(1, dir.dy));
435                        }
436                        if self.grid_map.is_valid_step(x, y, x + 1, y) {
437                            dirs.push(Direction::new(1, 0));
438                        }
439                    }
440                    let blocked_left = !self.grid_map.is_valid(x - 1, y - dir.dy);
441                    if blocked_left {
442                        if self.grid_map.is_valid_step(x, y, x - 1, y + dir.dy) {
443                            dirs.push(Direction::new(-1, dir.dy));
444                        }
445                        if self.grid_map.is_valid_step(x, y, x - 1, y) {
446                            dirs.push(Direction::new(-1, 0));
447                        }
448                    }
449                }
450
451                dirs
452            }
453        }
454    }
455
456    fn calc_distance(&self, x1: i32, y1: i32, x2: i32, y2: i32) -> f64 {
457        let dx = (x2 - x1).abs() as f64;
458        let dy = (y2 - y1).abs() as f64;
459        let d_min = dx.min(dy);
460        let d_max = dx.max(dy);
461        d_min * std::f64::consts::SQRT_2 + (d_max - d_min)
462    }
463
464    fn grid_point(&self, x: i32, y: i32) -> Point2D {
465        Point2D::new(
466            self.grid_map.calc_x_position(x),
467            self.grid_map.calc_y_position(y),
468        )
469    }
470
471    fn collect_jump_points(&self, goal_index: usize, node_storage: &[Node]) -> Vec<(i32, i32)> {
472        let mut jump_points = Vec::new();
473        let mut current_index = Some(goal_index);
474
475        while let Some(index) = current_index {
476            let node = &node_storage[index];
477            jump_points.push((node.x, node.y));
478            current_index = node.parent_index;
479        }
480
481        jump_points.reverse();
482        jump_points
483    }
484
485    fn append_reconstructed_segment(
486        &self,
487        points: &mut Vec<Point2D>,
488        from: (i32, i32),
489        to: (i32, i32),
490    ) -> f64 {
491        let (px, py) = from;
492        let (x, y) = to;
493        let dx = (x - px).signum();
494        let dy = (y - py).signum();
495        let mut cx = px;
496        let mut cy = py;
497        let mut reconstructed_length = 0.0;
498
499        while cx != x || cy != y {
500            let previous_x = cx;
501            let previous_y = cy;
502
503            if cx != x && cy != y {
504                if self.grid_map.is_valid_step(cx, cy, cx + dx, cy + dy) {
505                    cx += dx;
506                    cy += dy;
507                } else if self.grid_map.is_valid_step(cx, cy, cx + dx, cy) {
508                    cx += dx;
509                } else if self.grid_map.is_valid_step(cx, cy, cx, cy + dy) {
510                    cy += dy;
511                } else {
512                    cx += dx;
513                    cy += dy;
514                }
515            } else if cx != x {
516                cx += dx;
517            } else {
518                cy += dy;
519            }
520
521            reconstructed_length += self.calc_distance(previous_x, previous_y, cx, cy);
522            points.push(self.grid_point(cx, cy));
523        }
524
525        reconstructed_length
526    }
527
528    fn build_path_with_metrics(
529        &self,
530        goal_index: usize,
531        node_storage: &[Node],
532    ) -> (Path2D, Vec<JPSDetourSegment>) {
533        let jump_points = self.collect_jump_points(goal_index, node_storage);
534        let mut points = Vec::new();
535        let mut detour_segments = Vec::new();
536
537        if let Some(&(start_x, start_y)) = jump_points.first() {
538            points.push(self.grid_point(start_x, start_y));
539        }
540
541        for segment in jump_points.windows(2) {
542            let from = segment[0];
543            let to = segment[1];
544            let reconstructed_length = self.append_reconstructed_segment(&mut points, from, to);
545            let expected_length = self.calc_distance(from.0, from.1, to.0, to.1);
546            if (reconstructed_length - expected_length).abs() > 1e-6 {
547                detour_segments.push(JPSDetourSegment {
548                    from_x: from.0,
549                    from_y: from.1,
550                    to_x: to.0,
551                    to_y: to.1,
552                    expected_length,
553                    reconstructed_length,
554                });
555            }
556        }
557
558        (Path2D::from_points(points), detour_segments)
559    }
560
561    fn build_search_path(&self, goal_index: usize, node_storage: &[Node]) -> Path2D {
562        let points: Vec<Point2D> = self
563            .collect_jump_points(goal_index, node_storage)
564            .into_iter()
565            .map(|(x, y)| self.grid_point(x, y))
566            .collect();
567        Path2D::from_points(points)
568    }
569
570    fn cost_mismatch_metrics(
571        &self,
572        detail: Option<JPSInvalidJumpPathDetail>,
573        path: &Path2D,
574        search_cost: f64,
575        detour_segments: Vec<JPSDetourSegment>,
576    ) -> Option<JPSCostMismatchMetrics> {
577        match detail {
578            Some(JPSInvalidJumpPathDetail::CostMismatch)
579            | Some(JPSInvalidJumpPathDetail::InvalidStepSequenceAndCostMismatch) => {
580                let reconstructed_path_length = path.total_length();
581                Some(JPSCostMismatchMetrics {
582                    search_cost,
583                    reconstructed_path_length,
584                    delta: reconstructed_path_length - search_cost,
585                    detour_segment_count: detour_segments.len(),
586                    detour_segments,
587                })
588            }
589            _ => None,
590        }
591    }
592
593    fn path_has_only_valid_steps(&self, path: &Path2D) -> bool {
594        path.points.windows(2).all(|segment| {
595            let from_x = self.grid_map.calc_x_index(segment[0].x);
596            let from_y = self.grid_map.calc_y_index(segment[0].y);
597            let to_x = self.grid_map.calc_x_index(segment[1].x);
598            let to_y = self.grid_map.calc_y_index(segment[1].y);
599            self.grid_map.is_valid_step(from_x, from_y, to_x, to_y)
600        })
601    }
602
603    fn classify_invalid_jump_path(
604        &self,
605        path: &Path2D,
606        expected_cost: f64,
607    ) -> Option<JPSInvalidJumpPathDetail> {
608        let has_only_valid_steps = self.path_has_only_valid_steps(path);
609        let has_cost_mismatch = (path.total_length() - expected_cost).abs() > 1e-6;
610
611        match (has_only_valid_steps, has_cost_mismatch) {
612            (true, false) => None,
613            (false, false) => Some(JPSInvalidJumpPathDetail::InvalidStepSequence),
614            (true, true) => Some(JPSInvalidJumpPathDetail::CostMismatch),
615            (false, true) => Some(JPSInvalidJumpPathDetail::InvalidStepSequenceAndCostMismatch),
616        }
617    }
618
619    fn plan_with_grid_a_star(
620        &self,
621        start_x: i32,
622        start_y: i32,
623        goal_x: i32,
624        goal_y: i32,
625    ) -> RoboticsResult<Path2D> {
626        let mut open_set = BinaryHeap::new();
627        let mut closed_set: HashMap<i32, usize> = HashMap::new();
628        let mut node_storage: Vec<Node> = Vec::new();
629
630        node_storage.push(Node::new(start_x, start_y, 0.0, None));
631        open_set.push(PriorityNode {
632            x: start_x,
633            y: start_y,
634            cost: 0.0,
635            priority: self.calc_heuristic(start_x, start_y, goal_x, goal_y),
636            index: 0,
637        });
638
639        while let Some(current) = open_set.pop() {
640            let current_grid_index = self.grid_map.calc_index(current.x, current.y);
641
642            if current.x == goal_x && current.y == goal_y {
643                return Ok(self.build_search_path(current.index, &node_storage));
644            }
645
646            if let Some(&existing_index) = closed_set.get(&current_grid_index) {
647                if node_storage[existing_index].cost <= current.cost {
648                    continue;
649                }
650            }
651
652            closed_set.insert(current_grid_index, current.index);
653
654            for dx in -1..=1 {
655                for dy in -1..=1 {
656                    if dx == 0 && dy == 0 {
657                        continue;
658                    }
659
660                    if !self.grid_map.is_valid_offset(current.x, current.y, dx, dy) {
661                        continue;
662                    }
663
664                    let new_x = current.x + dx;
665                    let new_y = current.y + dy;
666                    let move_cost = if dx != 0 && dy != 0 {
667                        std::f64::consts::SQRT_2
668                    } else {
669                        1.0
670                    };
671                    let new_cost = current.cost + move_cost;
672                    let new_grid_index = self.grid_map.calc_index(new_x, new_y);
673
674                    if let Some(&existing_index) = closed_set.get(&new_grid_index) {
675                        if node_storage[existing_index].cost <= new_cost {
676                            continue;
677                        }
678                    }
679
680                    node_storage.push(Node::new(new_x, new_y, new_cost, Some(current.index)));
681                    let new_index = node_storage.len() - 1;
682                    open_set.push(PriorityNode {
683                        x: new_x,
684                        y: new_y,
685                        cost: new_cost,
686                        priority: new_cost + self.calc_heuristic(new_x, new_y, goal_x, goal_y),
687                        index: new_index,
688                    });
689                }
690            }
691        }
692
693        Err(RoboticsError::PlanningError("No path found".to_string()))
694    }
695
696    fn direct_result(&self, path: Path2D) -> JPSPlanResult {
697        JPSPlanResult {
698            path,
699            diagnostics: JPSPlanDiagnostics {
700                used_fallback: false,
701                fallback_reason: None,
702                invalid_jump_path_detail: None,
703                cost_mismatch_metrics: None,
704            },
705        }
706    }
707
708    fn fallback_result_with_path(
709        &self,
710        path: Path2D,
711        fallback_reason: JPSFallbackReason,
712        invalid_jump_path_detail: Option<JPSInvalidJumpPathDetail>,
713        cost_mismatch_metrics: Option<JPSCostMismatchMetrics>,
714    ) -> JPSPlanResult {
715        JPSPlanResult {
716            path,
717            diagnostics: JPSPlanDiagnostics {
718                used_fallback: true,
719                fallback_reason: Some(fallback_reason),
720                invalid_jump_path_detail,
721                cost_mismatch_metrics,
722            },
723        }
724    }
725
726    #[allow(clippy::too_many_arguments)]
727    fn fallback_result(
728        &self,
729        start_x: i32,
730        start_y: i32,
731        goal_x: i32,
732        goal_y: i32,
733        fallback_reason: JPSFallbackReason,
734        invalid_jump_path_detail: Option<JPSInvalidJumpPathDetail>,
735        cost_mismatch_metrics: Option<JPSCostMismatchMetrics>,
736    ) -> RoboticsResult<JPSPlanResult> {
737        self.plan_with_grid_a_star(start_x, start_y, goal_x, goal_y)
738            .map(|path| {
739                self.fallback_result_with_path(
740                    path,
741                    fallback_reason,
742                    invalid_jump_path_detail,
743                    cost_mismatch_metrics,
744                )
745            })
746    }
747
748    fn ensure_query_is_valid(&self, x: i32, y: i32, label: &str) -> RoboticsResult<()> {
749        if self.grid_map.is_valid(x, y) {
750            return Ok(());
751        }
752
753        Err(RoboticsError::PlanningError(format!(
754            "{} position is invalid",
755            label
756        )))
757    }
758
759    fn plan_impl(&self, start: Point2D, goal: Point2D) -> RoboticsResult<JPSPlanResult> {
760        let start_x = self.grid_map.calc_x_index(start.x);
761        let start_y = self.grid_map.calc_y_index(start.y);
762        let goal_x = self.grid_map.calc_x_index(goal.x);
763        let goal_y = self.grid_map.calc_y_index(goal.y);
764
765        self.ensure_query_is_valid(start_x, start_y, "Start")?;
766        self.ensure_query_is_valid(goal_x, goal_y, "Goal")?;
767
768        let mut open_set = BinaryHeap::new();
769        let mut closed_set: HashMap<(i32, Option<Direction>), usize> = HashMap::new();
770        let mut node_storage: Vec<Node> = Vec::new();
771        let mut direction_map: HashMap<usize, Option<Direction>> = HashMap::new();
772
773        node_storage.push(Node::new(start_x, start_y, 0.0, None));
774        let start_index = 0;
775        direction_map.insert(start_index, None);
776
777        open_set.push(PriorityNode {
778            x: start_x,
779            y: start_y,
780            cost: 0.0,
781            priority: self.calc_heuristic(start_x, start_y, goal_x, goal_y),
782            index: start_index,
783        });
784
785        while let Some(current) = open_set.pop() {
786            let current_dir = direction_map.get(&current.index).copied().flatten();
787            let current_state_key = self.state_key(current.x, current.y, current_dir);
788
789            if current.x == goal_x && current.y == goal_y {
790                let (path, detour_segments) =
791                    self.build_path_with_metrics(current.index, &node_storage);
792                let invalid_jump_path_detail = self.classify_invalid_jump_path(&path, current.cost);
793                if invalid_jump_path_detail.is_none() {
794                    let reference_path =
795                        self.plan_with_grid_a_star(start_x, start_y, goal_x, goal_y)?;
796                    if reference_path.total_length() + 1e-6 < path.total_length() {
797                        return Ok(self.fallback_result_with_path(
798                            reference_path,
799                            JPSFallbackReason::SuboptimalJumpPath,
800                            None,
801                            None,
802                        ));
803                    }
804                    return Ok(self.direct_result(path));
805                }
806                let cost_mismatch_metrics = self.cost_mismatch_metrics(
807                    invalid_jump_path_detail,
808                    &path,
809                    current.cost,
810                    detour_segments,
811                );
812
813                return self.fallback_result(
814                    start_x,
815                    start_y,
816                    goal_x,
817                    goal_y,
818                    JPSFallbackReason::InvalidJumpPath,
819                    invalid_jump_path_detail,
820                    cost_mismatch_metrics,
821                );
822            }
823
824            if let Some(&existing_index) = closed_set.get(&current_state_key) {
825                if node_storage[existing_index].cost <= current.cost {
826                    continue;
827                }
828            }
829
830            closed_set.insert(current_state_key, current.index);
831
832            let successors = self.get_successors(current.x, current.y, current_dir, goal_x, goal_y);
833
834            for (jx, jy, dir) in successors {
835                let new_cost = current.cost + self.calc_distance(current.x, current.y, jx, jy);
836                let new_state_key = self.state_key(jx, jy, Some(dir));
837
838                if let Some(&existing_index) = closed_set.get(&new_state_key) {
839                    if node_storage[existing_index].cost <= new_cost {
840                        continue;
841                    }
842                }
843
844                node_storage.push(Node::new(jx, jy, new_cost, Some(current.index)));
845                let new_index = node_storage.len() - 1;
846                direction_map.insert(new_index, Some(dir));
847
848                let priority = new_cost + self.calc_heuristic(jx, jy, goal_x, goal_y);
849                open_set.push(PriorityNode {
850                    x: jx,
851                    y: jy,
852                    cost: new_cost,
853                    priority,
854                    index: new_index,
855                });
856            }
857        }
858
859        self.fallback_result(
860            start_x,
861            start_y,
862            goal_x,
863            goal_y,
864            JPSFallbackReason::SearchExhausted,
865            None,
866            None,
867        )
868    }
869}
870
871impl PathPlanner for JPSPlanner {
872    fn plan(&self, start: Point2D, goal: Point2D) -> Result<Path2D, RoboticsError> {
873        self.plan_with_diagnostics(start, goal)
874            .map(|result| result.path)
875    }
876}
877
878#[cfg(test)]
879mod tests {
880    #![allow(dead_code)]
881
882    use super::*;
883
884    use crate::a_star::{AStarConfig, AStarPlanner};
885    use crate::moving_ai::{MovingAiMap, MovingAiScenario};
886    use rust_robotics_core::Obstacles;
887    use std::collections::{BinaryHeap, HashMap, HashSet};
888    use std::hint::black_box;
889    use std::time::Instant;
890
891    #[derive(Debug)]
892    struct RawTraceSuccessor {
893        x: i32,
894        y: i32,
895        dir: Direction,
896    }
897
898    #[derive(Debug)]
899    struct RawTraceExpansion {
900        node_index: usize,
901        parent_index: Option<usize>,
902        x: i32,
903        y: i32,
904        cost: f64,
905        parent_dir: Option<Direction>,
906        successors: Vec<RawTraceSuccessor>,
907    }
908
909    #[derive(Debug)]
910    enum RawTraceOutcome {
911        GoalReached {
912            jump_points: Vec<(i32, i32)>,
913            path_length: f64,
914            search_cost: f64,
915            invalid_jump_path_detail: Option<JPSInvalidJumpPathDetail>,
916        },
917        SearchExhausted,
918    }
919
920    #[derive(Debug)]
921    struct RawTrace {
922        expansions: Vec<RawTraceExpansion>,
923        outcome: RawTraceOutcome,
924    }
925
926    #[derive(Debug)]
927    struct RawJumpChainStats {
928        segment_count: usize,
929        short_segment_count: usize,
930        direction_change_count: usize,
931    }
932
933    #[derive(Debug)]
934    struct NodeProbeCandidate {
935        dir: Direction,
936        jump_target: Option<(i32, i32)>,
937    }
938
939    fn trace_raw_jps(
940        planner: &JPSPlanner,
941        start: Point2D,
942        goal: Point2D,
943        max_expansions: usize,
944    ) -> RawTrace {
945        let start_x = planner.grid_map.calc_x_index(start.x);
946        let start_y = planner.grid_map.calc_y_index(start.y);
947        let goal_x = planner.grid_map.calc_x_index(goal.x);
948        let goal_y = planner.grid_map.calc_y_index(goal.y);
949
950        planner
951            .ensure_query_is_valid(start_x, start_y, "Start")
952            .expect("trace start should be valid");
953        planner
954            .ensure_query_is_valid(goal_x, goal_y, "Goal")
955            .expect("trace goal should be valid");
956
957        let mut open_set = BinaryHeap::new();
958        let mut closed_set: HashMap<(i32, Option<Direction>), usize> = HashMap::new();
959        let mut node_storage: Vec<Node> = Vec::new();
960        let mut direction_map: HashMap<usize, Option<Direction>> = HashMap::new();
961        let mut expansions = Vec::new();
962
963        node_storage.push(Node::new(start_x, start_y, 0.0, None));
964        direction_map.insert(0, None);
965        open_set.push(PriorityNode {
966            x: start_x,
967            y: start_y,
968            cost: 0.0,
969            priority: planner.calc_heuristic(start_x, start_y, goal_x, goal_y),
970            index: 0,
971        });
972
973        while let Some(current) = open_set.pop() {
974            let current_dir = direction_map.get(&current.index).copied().flatten();
975            let current_state_key = planner.state_key(current.x, current.y, current_dir);
976
977            if current.x == goal_x && current.y == goal_y {
978                let (path, _) = planner.build_path_with_metrics(current.index, &node_storage);
979                let invalid_jump_path_detail =
980                    planner.classify_invalid_jump_path(&path, current.cost);
981                return RawTrace {
982                    expansions,
983                    outcome: RawTraceOutcome::GoalReached {
984                        jump_points: planner.collect_jump_points(current.index, &node_storage),
985                        path_length: path.total_length(),
986                        search_cost: current.cost,
987                        invalid_jump_path_detail,
988                    },
989                };
990            }
991
992            if let Some(&existing_index) = closed_set.get(&current_state_key) {
993                if node_storage[existing_index].cost <= current.cost {
994                    continue;
995                }
996            }
997
998            closed_set.insert(current_state_key, current.index);
999
1000            let successors =
1001                planner.get_successors(current.x, current.y, current_dir, goal_x, goal_y);
1002            let trace_successors = successors
1003                .iter()
1004                .map(|(x, y, dir)| RawTraceSuccessor {
1005                    x: *x,
1006                    y: *y,
1007                    dir: *dir,
1008                })
1009                .collect();
1010            expansions.push(RawTraceExpansion {
1011                node_index: current.index,
1012                parent_index: node_storage[current.index].parent_index,
1013                x: current.x,
1014                y: current.y,
1015                cost: current.cost,
1016                parent_dir: current_dir,
1017                successors: trace_successors,
1018            });
1019            if expansions.len() >= max_expansions {
1020                return RawTrace {
1021                    expansions,
1022                    outcome: RawTraceOutcome::SearchExhausted,
1023                };
1024            }
1025
1026            for (jx, jy, dir) in successors {
1027                let new_cost = current.cost + planner.calc_distance(current.x, current.y, jx, jy);
1028                let new_state_key = planner.state_key(jx, jy, Some(dir));
1029
1030                if let Some(&existing_index) = closed_set.get(&new_state_key) {
1031                    if node_storage[existing_index].cost <= new_cost {
1032                        continue;
1033                    }
1034                }
1035
1036                node_storage.push(Node::new(jx, jy, new_cost, Some(current.index)));
1037                let new_index = node_storage.len() - 1;
1038                direction_map.insert(new_index, Some(dir));
1039                open_set.push(PriorityNode {
1040                    x: jx,
1041                    y: jy,
1042                    cost: new_cost,
1043                    priority: new_cost + planner.calc_heuristic(jx, jy, goal_x, goal_y),
1044                    index: new_index,
1045                });
1046            }
1047        }
1048
1049        RawTrace {
1050            expansions,
1051            outcome: RawTraceOutcome::SearchExhausted,
1052        }
1053    }
1054
1055    fn trace_chain_to_root(
1056        expansions: &[RawTraceExpansion],
1057        terminal_node_index: usize,
1058    ) -> Vec<&RawTraceExpansion> {
1059        let expansion_index_by_node: HashMap<usize, usize> = expansions
1060            .iter()
1061            .enumerate()
1062            .map(|(ix, expansion)| (expansion.node_index, ix))
1063            .collect();
1064
1065        let mut chain = Vec::new();
1066        let mut current = Some(terminal_node_index);
1067        while let Some(node_index) = current {
1068            let Some(&expansion_ix) = expansion_index_by_node.get(&node_index) else {
1069                break;
1070            };
1071            let expansion = &expansions[expansion_ix];
1072            chain.push(expansion);
1073            current = expansion.parent_index;
1074        }
1075        chain.reverse();
1076        chain
1077    }
1078
1079    fn summarize_jump_chain(jump_points: &[(i32, i32)]) -> RawJumpChainStats {
1080        if jump_points.len() < 2 {
1081            return RawJumpChainStats {
1082                segment_count: 0,
1083                short_segment_count: 0,
1084                direction_change_count: 0,
1085            };
1086        }
1087
1088        let mut short_segment_count = 0;
1089        let mut directions = Vec::new();
1090
1091        for segment in jump_points.windows(2) {
1092            let dx = segment[1].0 - segment[0].0;
1093            let dy = segment[1].1 - segment[0].1;
1094            let length = ((dx * dx + dy * dy) as f64).sqrt();
1095            if length <= std::f64::consts::SQRT_2 + 1e-6 {
1096                short_segment_count += 1;
1097            }
1098            directions.push((dx.signum(), dy.signum()));
1099        }
1100
1101        let direction_change_count = directions
1102            .windows(2)
1103            .filter(|pair| pair[0] != pair[1])
1104            .count();
1105
1106        RawJumpChainStats {
1107            segment_count: jump_points.len() - 1,
1108            short_segment_count,
1109            direction_change_count,
1110        }
1111    }
1112
1113    fn probe_node(
1114        planner: &JPSPlanner,
1115        x: i32,
1116        y: i32,
1117        parent_dir: Option<Direction>,
1118        goal_x: i32,
1119        goal_y: i32,
1120    ) -> Vec<NodeProbeCandidate> {
1121        planner
1122            .get_neighbors(x, y, parent_dir)
1123            .into_iter()
1124            .map(|dir| NodeProbeCandidate {
1125                jump_target: planner.jump(x, y, dir, goal_x, goal_y),
1126                dir,
1127            })
1128            .collect()
1129    }
1130
1131    fn densest_direction_change_window(
1132        jump_points: &[(i32, i32)],
1133        window_segments: usize,
1134    ) -> Option<(usize, usize, usize)> {
1135        if jump_points.len() < window_segments + 1 || window_segments < 2 {
1136            return None;
1137        }
1138
1139        let directions: Vec<(i32, i32)> = jump_points
1140            .windows(2)
1141            .map(|segment| {
1142                (
1143                    (segment[1].0 - segment[0].0).signum(),
1144                    (segment[1].1 - segment[0].1).signum(),
1145                )
1146            })
1147            .collect();
1148
1149        let mut best: Option<(usize, usize, usize)> = None;
1150        for start in 0..=directions.len() - window_segments {
1151            let end = start + window_segments;
1152            let change_count = directions[start..end]
1153                .windows(2)
1154                .filter(|pair| pair[0] != pair[1])
1155                .count();
1156            if best
1157                .map(|(_, _, best_count)| change_count > best_count)
1158                .unwrap_or(true)
1159            {
1160                best = Some((start, end, change_count));
1161            }
1162        }
1163        best
1164    }
1165
1166    fn first_short_segment_run(
1167        jump_points: &[(i32, i32)],
1168        min_run_len: usize,
1169    ) -> Option<(usize, usize)> {
1170        if jump_points.len() < 2 {
1171            return None;
1172        }
1173
1174        let mut run_start = None;
1175        let mut run_len = 0usize;
1176        for (ix, segment) in jump_points.windows(2).enumerate() {
1177            let dx = segment[1].0 - segment[0].0;
1178            let dy = segment[1].1 - segment[0].1;
1179            let length = ((dx * dx + dy * dy) as f64).sqrt();
1180            if length <= std::f64::consts::SQRT_2 + 1e-6 {
1181                if run_start.is_none() {
1182                    run_start = Some(ix);
1183                }
1184                run_len += 1;
1185                if run_len >= min_run_len {
1186                    return run_start.map(|start| (start, run_len));
1187                }
1188            } else {
1189                run_start = None;
1190                run_len = 0;
1191            }
1192        }
1193        None
1194    }
1195
1196    fn grid_points_from_path(planner: &JPSPlanner, path: &Path2D) -> Vec<(i32, i32)> {
1197        path.points
1198            .iter()
1199            .map(|point| {
1200                (
1201                    planner.grid_map.calc_x_index(point.x),
1202                    planner.grid_map.calc_y_index(point.y),
1203                )
1204            })
1205            .collect()
1206    }
1207
1208    fn turn_points_from_grid_path(points: &[(i32, i32)]) -> Vec<(i32, i32)> {
1209        if points.len() < 2 {
1210            return points.to_vec();
1211        }
1212
1213        let mut turn_points = vec![points[0]];
1214        let mut last_dir = (
1215            (points[1].0 - points[0].0).signum(),
1216            (points[1].1 - points[0].1).signum(),
1217        );
1218
1219        for window in points.windows(3) {
1220            let current_dir = (
1221                (window[2].0 - window[1].0).signum(),
1222                (window[2].1 - window[1].1).signum(),
1223            );
1224            if current_dir != last_dir {
1225                turn_points.push(window[1]);
1226                last_dir = current_dir;
1227            }
1228        }
1229
1230        turn_points.push(*points.last().unwrap());
1231        turn_points
1232    }
1233
1234    fn first_missing_reference_turn_point(
1235        reference_turn_points: &[(i32, i32)],
1236        raw_trace: &RawTrace,
1237    ) -> Option<(usize, (i32, i32), bool, bool)> {
1238        let expanded_positions: HashSet<(i32, i32)> = raw_trace
1239            .expansions
1240            .iter()
1241            .map(|expansion| (expansion.x, expansion.y))
1242            .collect();
1243        let generated_positions: HashSet<(i32, i32)> = raw_trace
1244            .expansions
1245            .iter()
1246            .flat_map(|expansion| expansion.successors.iter().map(|succ| (succ.x, succ.y)))
1247            .collect();
1248
1249        reference_turn_points
1250            .iter()
1251            .enumerate()
1252            .skip(1)
1253            .find_map(|(ix, &point)| {
1254                let expanded = expanded_positions.contains(&point);
1255                let generated = generated_positions.contains(&point);
1256                if expanded || generated {
1257                    None
1258                } else {
1259                    Some((ix, point, expanded, generated))
1260                }
1261            })
1262    }
1263
1264    #[allow(clippy::type_complexity)]
1265    fn first_divergent_turn_point(
1266        reference_turn_points: &[(i32, i32)],
1267        raw_jump_points: &[(i32, i32)],
1268    ) -> Option<(usize, Option<(i32, i32)>, Option<(i32, i32)>)> {
1269        let max_len = reference_turn_points.len().max(raw_jump_points.len());
1270        for ix in 0..max_len {
1271            let reference_point = reference_turn_points.get(ix).copied();
1272            let raw_point = raw_jump_points.get(ix).copied();
1273            if reference_point != raw_point {
1274                return Some((ix, reference_point, raw_point));
1275            }
1276        }
1277        None
1278    }
1279
1280    fn create_simple_obstacles() -> (Vec<f64>, Vec<f64>) {
1281        let mut ox = Vec::new();
1282        let mut oy = Vec::new();
1283
1284        for i in 0..61 {
1285            ox.push(i as f64);
1286            oy.push(0.0);
1287            ox.push(i as f64);
1288            oy.push(60.0);
1289            ox.push(0.0);
1290            oy.push(i as f64);
1291            ox.push(60.0);
1292            oy.push(i as f64);
1293        }
1294
1295        for i in 20..40 {
1296            ox.push(30.0);
1297            oy.push(i as f64);
1298        }
1299
1300        (ox, oy)
1301    }
1302
1303    const MOVING_AI_CASES: [(&str, &str, &str); 3] = [
1304        (
1305            "arena2",
1306            include_str!("../benchdata/moving_ai/dao/arena2.map"),
1307            include_str!("../benchdata/moving_ai/dao/arena2.map.scen"),
1308        ),
1309        (
1310            "8room_000",
1311            include_str!("../benchdata/moving_ai/room/8room_000.map"),
1312            include_str!("../benchdata/moving_ai/room/8room_000.map.scen"),
1313        ),
1314        (
1315            "random512-10-0",
1316            include_str!("../benchdata/moving_ai/random/random512-10-0.map"),
1317            include_str!("../benchdata/moving_ai/random/random512-10-0.map.scen"),
1318        ),
1319    ];
1320
1321    fn assert_pure_jps_matches_moving_ai_bucket(
1322        name: &str,
1323        map_str: &str,
1324        scen_str: &str,
1325        bucket: u32,
1326    ) {
1327        let map = MovingAiMap::parse_str(map_str)
1328            .unwrap_or_else(|_| panic!("{name} MovingAI map should parse"));
1329        let scenario = MovingAiScenario::parse_str(scen_str)
1330            .unwrap_or_else(|_| panic!("{name} MovingAI scenarios should parse"))
1331            .into_iter()
1332            .find(|row| row.bucket == bucket)
1333            .unwrap_or_else(|| panic!("{name} MovingAI bucket {bucket} scenario should exist"));
1334
1335        let planner = JPSPlanner::from_obstacle_points(
1336            &map.to_obstacles(),
1337            JPSConfig {
1338                resolution: 1.0,
1339                robot_radius: 0.5,
1340                heuristic_weight: 1.0,
1341            },
1342        )
1343        .unwrap_or_else(|_| panic!("JPS planner should build from {name} obstacles"));
1344
1345        let start = map
1346            .planning_point(scenario.start_x, scenario.start_y)
1347            .unwrap_or_else(|_| panic!("{name} start should be valid"));
1348        let goal = map
1349            .planning_point(scenario.goal_x, scenario.goal_y)
1350            .unwrap_or_else(|_| panic!("{name} goal should be valid"));
1351
1352        let result = planner
1353            .plan_with_diagnostics(start, goal)
1354            .unwrap_or_else(|_| panic!("JPS should solve the {name} bucket {bucket} scenario"));
1355
1356        assert!(
1357            !result.path.is_empty(),
1358            "{name} bucket {bucket} path should not be empty"
1359        );
1360        println!(
1361            "{name}/bucket{bucket}: used_fallback={}, reason={:?}, detail={:?}, path_length={:.6}",
1362            result.diagnostics.used_fallback,
1363            result.diagnostics.fallback_reason,
1364            result.diagnostics.invalid_jump_path_detail,
1365            result.path.total_length()
1366        );
1367        assert!(
1368            !result.diagnostics.used_fallback,
1369            "{name} bucket {bucket} should solve as pure JPS, got reason={:?}, detail={:?}, path_length={:.6}",
1370            result.diagnostics.fallback_reason,
1371            result.diagnostics.invalid_jump_path_detail,
1372            result.path.total_length()
1373        );
1374        assert_eq!(
1375            result.diagnostics.fallback_reason, None,
1376            "{name} bucket {bucket} pure-JPS regression should not report a fallback reason"
1377        );
1378        assert_eq!(
1379            result.diagnostics.invalid_jump_path_detail,
1380            None,
1381            "{name} bucket {bucket} pure-JPS regression should not report an invalid_jump_path detail"
1382        );
1383        assert!(
1384            (result.path.total_length() - scenario.optimal_length).abs() <= 1e-6,
1385            "{name} bucket {bucket} pure JPS path length {} should match MovingAI optimal {}",
1386            result.path.total_length(),
1387            scenario.optimal_length
1388        );
1389
1390        let grid = planner.grid_map();
1391        for segment in result.path.points.windows(2) {
1392            let from_x = grid.calc_x_index(segment[0].x);
1393            let from_y = grid.calc_y_index(segment[0].y);
1394            let to_x = grid.calc_x_index(segment[1].x);
1395            let to_y = grid.calc_y_index(segment[1].y);
1396            assert!(
1397                grid.is_valid_step(from_x, from_y, to_x, to_y),
1398                "{name} bucket {bucket} path contains an invalid step from ({from_x}, {from_y}) to ({to_x}, {to_y})"
1399            );
1400        }
1401    }
1402
1403    fn build_moving_ai_bucket_planners(
1404        name: &str,
1405        map_str: &str,
1406        scen_str: &str,
1407        bucket: u32,
1408    ) -> (AStarPlanner, JPSPlanner, Point2D, Point2D, f64) {
1409        let map = MovingAiMap::parse_str(map_str)
1410            .unwrap_or_else(|_| panic!("{name} MovingAI map should parse"));
1411        let scenario = MovingAiScenario::parse_str(scen_str)
1412            .unwrap_or_else(|_| panic!("{name} MovingAI scenarios should parse"))
1413            .into_iter()
1414            .find(|row| row.bucket == bucket)
1415            .unwrap_or_else(|| panic!("{name} MovingAI bucket {bucket} scenario should exist"));
1416        let obstacles = map.to_obstacles();
1417        let a_star = AStarPlanner::from_obstacle_points(
1418            &obstacles,
1419            AStarConfig {
1420                resolution: 1.0,
1421                robot_radius: 0.5,
1422                heuristic_weight: 1.0,
1423            },
1424        )
1425        .unwrap_or_else(|_| panic!("A* planner should build from {name} obstacles"));
1426        let jps = JPSPlanner::from_obstacle_points(
1427            &obstacles,
1428            JPSConfig {
1429                resolution: 1.0,
1430                robot_radius: 0.5,
1431                heuristic_weight: 1.0,
1432            },
1433        )
1434        .unwrap_or_else(|_| panic!("JPS planner should build from {name} obstacles"));
1435        let start = map
1436            .planning_point(scenario.start_x, scenario.start_y)
1437            .unwrap_or_else(|_| panic!("{name} start should be valid"));
1438        let goal = map
1439            .planning_point(scenario.goal_x, scenario.goal_y)
1440            .unwrap_or_else(|_| panic!("{name} goal should be valid"));
1441
1442        (a_star, jps, start, goal, scenario.optimal_length)
1443    }
1444
1445    fn build_moving_ai_family_planners(
1446        name: &str,
1447        map_str: &str,
1448        scen_str: &str,
1449    ) -> (MovingAiMap, Vec<MovingAiScenario>, AStarPlanner, JPSPlanner) {
1450        let map = MovingAiMap::parse_str(map_str)
1451            .unwrap_or_else(|_| panic!("{name} MovingAI map should parse"));
1452        let scenarios = MovingAiScenario::parse_str(scen_str)
1453            .unwrap_or_else(|_| panic!("{name} MovingAI scenarios should parse"));
1454        let obstacles = map.to_obstacles();
1455        let a_star = AStarPlanner::from_obstacle_points(
1456            &obstacles,
1457            AStarConfig {
1458                resolution: 1.0,
1459                robot_radius: 0.5,
1460                heuristic_weight: 1.0,
1461            },
1462        )
1463        .unwrap_or_else(|_| panic!("A* planner should build from {name} obstacles"));
1464        let jps = JPSPlanner::from_obstacle_points(
1465            &obstacles,
1466            JPSConfig {
1467                resolution: 1.0,
1468                robot_radius: 0.5,
1469                heuristic_weight: 1.0,
1470            },
1471        )
1472        .unwrap_or_else(|_| panic!("JPS planner should build from {name} obstacles"));
1473
1474        (map, scenarios, a_star, jps)
1475    }
1476
1477    fn build_sampled_moving_ai_bucket_cases(
1478        name: &str,
1479        map: &MovingAiMap,
1480        scenarios: &[MovingAiScenario],
1481        bucket: u32,
1482        sample_slots: &[usize],
1483    ) -> Vec<(usize, Point2D, Point2D, f64)> {
1484        let bucket_scenarios: Vec<&MovingAiScenario> = scenarios
1485            .iter()
1486            .filter(|row| row.bucket == bucket)
1487            .collect();
1488        assert!(
1489            !bucket_scenarios.is_empty(),
1490            "{name} MovingAI bucket {bucket} scenarios should exist"
1491        );
1492        let max_slot = sample_slots.iter().copied().max().unwrap_or(0);
1493        assert!(
1494            bucket_scenarios.len() > max_slot,
1495            "{name} bucket {bucket} should have at least {} scenarios, found {}",
1496            max_slot + 1,
1497            bucket_scenarios.len()
1498        );
1499
1500        sample_slots
1501            .iter()
1502            .map(|&slot| {
1503                let scenario = bucket_scenarios[slot];
1504                let start = map
1505                    .planning_point(scenario.start_x, scenario.start_y)
1506                    .unwrap_or_else(|_| panic!("{name} start should be valid"));
1507                let goal = map
1508                    .planning_point(scenario.goal_x, scenario.goal_y)
1509                    .unwrap_or_else(|_| panic!("{name} goal should be valid"));
1510                (slot, start, goal, scenario.optimal_length)
1511            })
1512            .collect()
1513    }
1514
1515    fn measure_planner_median_us<F>(iterations: usize, mut plan: F) -> f64
1516    where
1517        F: FnMut() -> Path2D,
1518    {
1519        let mut samples = Vec::with_capacity(iterations);
1520        for _ in 0..iterations {
1521            let started = Instant::now();
1522            let path = plan();
1523            black_box(path.points.len());
1524            black_box(path.total_length());
1525            samples.push(started.elapsed().as_secs_f64() * 1_000_000.0);
1526        }
1527        samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
1528        samples[iterations / 2]
1529    }
1530
1531    fn median_value(mut samples: Vec<f64>) -> f64 {
1532        assert!(
1533            !samples.is_empty(),
1534            "median_value requires at least one sample"
1535        );
1536        samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
1537        samples[samples.len() / 2]
1538    }
1539
1540    #[derive(Debug, Clone, Copy)]
1541    struct BucketRuntimeAggregate {
1542        a_star_median_us: f64,
1543        jps_median_us: f64,
1544        a_star_min_us: f64,
1545        a_star_max_us: f64,
1546        jps_min_us: f64,
1547        jps_max_us: f64,
1548        jps_wins: usize,
1549        sample_count: usize,
1550    }
1551
1552    #[allow(clippy::too_many_arguments)]
1553    fn measure_moving_ai_bucket_runtime_aggregate(
1554        name: &str,
1555        map: &MovingAiMap,
1556        scenarios: &[MovingAiScenario],
1557        a_star: &AStarPlanner,
1558        jps: &JPSPlanner,
1559        bucket: u32,
1560        sample_slots: &[usize],
1561        iterations: usize,
1562    ) -> BucketRuntimeAggregate {
1563        let sampled_cases =
1564            build_sampled_moving_ai_bucket_cases(name, map, scenarios, bucket, sample_slots);
1565        let mut a_star_samples = Vec::with_capacity(sampled_cases.len());
1566        let mut jps_samples = Vec::with_capacity(sampled_cases.len());
1567        let mut jps_wins = 0usize;
1568
1569        for (slot, start, goal, optimal_length) in sampled_cases {
1570            let warmup_a = a_star
1571                .plan(start, goal)
1572                .unwrap_or_else(|_| panic!("A* should solve {name} bucket {bucket} slot {slot}"));
1573            assert!(
1574                (warmup_a.total_length() - optimal_length).abs() <= 1e-6,
1575                "A* warmup path for {name} bucket {bucket} slot {slot} should match MovingAI optimal {}",
1576                optimal_length
1577            );
1578            let warmup_j = jps
1579                .plan(start, goal)
1580                .unwrap_or_else(|_| panic!("JPS should solve {name} bucket {bucket} slot {slot}"));
1581            assert!(
1582                (warmup_j.total_length() - optimal_length).abs() <= 1e-6,
1583                "JPS warmup path for {name} bucket {bucket} slot {slot} should match MovingAI optimal {}",
1584                optimal_length
1585            );
1586
1587            let a_star_median_us = measure_planner_median_us(iterations, || {
1588                let path = a_star.plan(start, goal).unwrap_or_else(|_| {
1589                    panic!("A* should solve {name} bucket {bucket} slot {slot}")
1590                });
1591                assert!(
1592                    (path.total_length() - optimal_length).abs() <= 1e-6,
1593                    "A* path length {} should match MovingAI optimal {} on {name} bucket {bucket} slot {slot}",
1594                    path.total_length(),
1595                    optimal_length
1596                );
1597                path
1598            });
1599            let jps_median_us = measure_planner_median_us(iterations, || {
1600                let path = jps.plan(start, goal).unwrap_or_else(|_| {
1601                    panic!("JPS should solve {name} bucket {bucket} slot {slot}")
1602                });
1603                assert!(
1604                    (path.total_length() - optimal_length).abs() <= 1e-6,
1605                    "JPS path length {} should match MovingAI optimal {} on {name} bucket {bucket} slot {slot}",
1606                    path.total_length(),
1607                    optimal_length
1608                );
1609                path
1610            });
1611            if jps_median_us < a_star_median_us {
1612                jps_wins += 1;
1613            }
1614            a_star_samples.push(a_star_median_us);
1615            jps_samples.push(jps_median_us);
1616        }
1617
1618        let a_star_median_us = median_value(a_star_samples.clone());
1619        let jps_median_us = median_value(jps_samples.clone());
1620        let a_star_min_us = a_star_samples.iter().copied().fold(f64::INFINITY, f64::min);
1621        let a_star_max_us = a_star_samples
1622            .iter()
1623            .copied()
1624            .fold(f64::NEG_INFINITY, f64::max);
1625        let jps_min_us = jps_samples.iter().copied().fold(f64::INFINITY, f64::min);
1626        let jps_max_us = jps_samples
1627            .iter()
1628            .copied()
1629            .fold(f64::NEG_INFINITY, f64::max);
1630
1631        BucketRuntimeAggregate {
1632            a_star_median_us,
1633            jps_median_us,
1634            a_star_min_us,
1635            a_star_max_us,
1636            jps_min_us,
1637            jps_max_us,
1638            jps_wins,
1639            sample_count: sample_slots.len(),
1640        }
1641    }
1642
1643    fn create_small_obstacles() -> (Vec<f64>, Vec<f64>) {
1644        let mut ox = Vec::new();
1645        let mut oy = Vec::new();
1646
1647        for i in 0..11 {
1648            ox.push(i as f64);
1649            oy.push(0.0);
1650            ox.push(i as f64);
1651            oy.push(10.0);
1652            ox.push(0.0);
1653            oy.push(i as f64);
1654            ox.push(10.0);
1655            oy.push(i as f64);
1656        }
1657
1658        for i in 4..7 {
1659            ox.push(5.0);
1660            oy.push(i as f64);
1661        }
1662
1663        (ox, oy)
1664    }
1665
1666    fn draw_horizontal_line(
1667        start_x: i32,
1668        start_y: i32,
1669        length: i32,
1670        ox: &mut Vec<f64>,
1671        oy: &mut Vec<f64>,
1672    ) {
1673        for i in start_x..start_x + length {
1674            for j in start_y..start_y + 2 {
1675                ox.push(i as f64);
1676                oy.push(j as f64);
1677            }
1678        }
1679    }
1680
1681    fn draw_vertical_line(
1682        start_x: i32,
1683        start_y: i32,
1684        length: i32,
1685        ox: &mut Vec<f64>,
1686        oy: &mut Vec<f64>,
1687    ) {
1688        for i in start_x..start_x + 2 {
1689            for j in start_y..start_y + length {
1690                ox.push(i as f64);
1691                oy.push(j as f64);
1692            }
1693        }
1694    }
1695
1696    fn create_upstream_jump_point_maze() -> (Vec<f64>, Vec<f64>) {
1697        let mut ox = Vec::new();
1698        let mut oy = Vec::new();
1699
1700        draw_vertical_line(0, 0, 50, &mut ox, &mut oy);
1701        draw_vertical_line(48, 0, 50, &mut ox, &mut oy);
1702        draw_horizontal_line(0, 0, 50, &mut ox, &mut oy);
1703        draw_horizontal_line(0, 48, 50, &mut ox, &mut oy);
1704
1705        let vertical_x = [10, 10, 10, 15, 20, 20, 30, 30, 35, 30, 40, 45];
1706        let vertical_y = [10, 30, 45, 20, 5, 40, 10, 40, 5, 40, 10, 25];
1707        let vertical_len = [10, 10, 5, 10, 10, 5, 20, 10, 25, 10, 35, 15];
1708        for ((x, y), len) in vertical_x
1709            .iter()
1710            .zip(vertical_y.iter())
1711            .zip(vertical_len.iter())
1712        {
1713            draw_vertical_line(*x, *y, *len, &mut ox, &mut oy);
1714        }
1715
1716        let horizontal_x = [35, 40, 15, 10, 45, 20, 10, 15, 25, 45, 10, 30, 10, 40];
1717        let horizontal_y = [5, 10, 15, 20, 20, 25, 30, 35, 35, 35, 40, 40, 45, 45];
1718        let horizontal_len = [10, 5, 10, 10, 5, 5, 10, 5, 10, 5, 10, 5, 5, 5];
1719        for ((x, y), len) in horizontal_x
1720            .iter()
1721            .zip(horizontal_y.iter())
1722            .zip(horizontal_len.iter())
1723        {
1724            draw_horizontal_line(*x, *y, *len, &mut ox, &mut oy);
1725        }
1726
1727        (ox, oy)
1728    }
1729
1730    fn upstream_jump_point_reference_polyline() -> Vec<Point2D> {
1731        vec![
1732            Point2D::new(5.0, 5.0),
1733            Point2D::new(7.0, 6.0),
1734            Point2D::new(9.0, 10.0),
1735            Point2D::new(9.0, 15.0),
1736            Point2D::new(9.0, 20.0),
1737            Point2D::new(10.0, 22.0),
1738            Point2D::new(11.0, 26.0),
1739            Point2D::new(10.0, 29.0),
1740            Point2D::new(9.0, 33.0),
1741            Point2D::new(9.0, 38.0),
1742            Point2D::new(10.0, 42.0),
1743            Point2D::new(14.0, 44.0),
1744            Point2D::new(19.0, 44.0),
1745            Point2D::new(22.0, 47.0),
1746            Point2D::new(25.0, 45.0),
1747            Point2D::new(29.0, 43.0),
1748            Point2D::new(29.0, 40.0),
1749            Point2D::new(32.0, 39.0),
1750            Point2D::new(35.0, 40.0),
1751            Point2D::new(36.0, 44.0),
1752            Point2D::new(35.0, 45.0),
1753        ]
1754    }
1755
1756    fn polyline_length(points: &[Point2D]) -> f64 {
1757        points
1758            .windows(2)
1759            .map(|window| window[0].distance(&window[1]))
1760            .sum()
1761    }
1762
1763    #[test]
1764    fn test_jps_finds_path() {
1765        let (ox, oy) = create_simple_obstacles();
1766        let planner = JPSPlanner::from_obstacles(&ox, &oy, 1.0, 0.5);
1767
1768        let start = Point2D::new(10.0, 10.0);
1769        let goal = Point2D::new(50.0, 50.0);
1770
1771        let result = planner.plan(start, goal);
1772        assert!(result.is_ok());
1773
1774        let path = result.unwrap();
1775        assert!(!path.is_empty());
1776
1777        let first = &path.points[0];
1778        let last = &path.points[path.len() - 1];
1779        assert!((first.x - 10.0).abs() < 2.0);
1780        assert!((first.y - 10.0).abs() < 2.0);
1781        assert!((last.x - 50.0).abs() < 2.0);
1782        assert!((last.y - 50.0).abs() < 2.0);
1783    }
1784
1785    #[test]
1786    fn test_jps_small_map() {
1787        let (ox, oy) = create_small_obstacles();
1788        let planner = JPSPlanner::from_obstacles(&ox, &oy, 1.0, 0.5);
1789
1790        let start = Point2D::new(2.0, 2.0);
1791        let goal = Point2D::new(8.0, 8.0);
1792
1793        let result = planner.plan(start, goal);
1794        assert!(result.is_ok());
1795
1796        let path = result.unwrap();
1797        assert!(!path.is_empty());
1798    }
1799
1800    #[test]
1801    fn test_jps_diagnostics_are_consistent_on_small_map() {
1802        let (ox, oy) = create_small_obstacles();
1803        let planner = JPSPlanner::from_obstacles(&ox, &oy, 1.0, 0.5);
1804
1805        let result = planner
1806            .plan_with_diagnostics(Point2D::new(2.0, 2.0), Point2D::new(8.0, 8.0))
1807            .expect("small-map JPS query should succeed");
1808
1809        assert!(!result.path.is_empty());
1810        assert_eq!(
1811            result.diagnostics.used_fallback,
1812            result.diagnostics.fallback_reason.is_some()
1813        );
1814        if result.diagnostics.fallback_reason != Some(JPSFallbackReason::InvalidJumpPath) {
1815            assert_eq!(result.diagnostics.invalid_jump_path_detail, None);
1816            assert_eq!(result.diagnostics.cost_mismatch_metrics, None);
1817        }
1818        if !matches!(
1819            result.diagnostics.invalid_jump_path_detail,
1820            Some(JPSInvalidJumpPathDetail::CostMismatch)
1821                | Some(JPSInvalidJumpPathDetail::InvalidStepSequenceAndCostMismatch)
1822        ) {
1823            assert_eq!(result.diagnostics.cost_mismatch_metrics, None);
1824        }
1825    }
1826
1827    #[test]
1828    #[allow(deprecated)]
1829    fn test_jps_legacy_interface() {
1830        let (ox, oy) = create_small_obstacles();
1831        let planner = JPSPlanner::from_obstacles(&ox, &oy, 1.0, 0.5);
1832
1833        let result = planner.planning(2.0, 2.0, 8.0, 8.0);
1834        assert!(result.is_some());
1835
1836        let (rx, ry) = result.unwrap();
1837        assert!(!rx.is_empty());
1838        assert_eq!(rx.len(), ry.len());
1839    }
1840
1841    #[test]
1842    fn test_jps_no_path() {
1843        let mut ox = Vec::new();
1844        let mut oy = Vec::new();
1845
1846        for i in 0..10 {
1847            ox.push(i as f64);
1848            oy.push(0.0);
1849            ox.push(i as f64);
1850            oy.push(9.0);
1851            ox.push(0.0);
1852            oy.push(i as f64);
1853            ox.push(9.0);
1854            oy.push(i as f64);
1855        }
1856
1857        for i in 1..9 {
1858            ox.push(5.0);
1859            oy.push(i as f64);
1860        }
1861
1862        let planner = JPSPlanner::from_obstacles(&ox, &oy, 1.0, 0.5);
1863
1864        let start = Point2D::new(2.0, 5.0);
1865        let goal = Point2D::new(7.0, 5.0);
1866
1867        let result = planner.plan(start, goal);
1868        assert!(result.is_err());
1869    }
1870
1871    #[test]
1872    fn test_jps_diagonal_path() {
1873        let mut ox = Vec::new();
1874        let mut oy = Vec::new();
1875
1876        for i in 0..21 {
1877            ox.push(i as f64);
1878            oy.push(0.0);
1879            ox.push(i as f64);
1880            oy.push(20.0);
1881            ox.push(0.0);
1882            oy.push(i as f64);
1883            ox.push(20.0);
1884            oy.push(i as f64);
1885        }
1886
1887        let planner = JPSPlanner::from_obstacles(&ox, &oy, 1.0, 0.5);
1888
1889        let start = Point2D::new(2.0, 2.0);
1890        let goal = Point2D::new(18.0, 18.0);
1891
1892        let result = planner.plan(start, goal);
1893        assert!(result.is_ok());
1894
1895        let path = result.unwrap();
1896        assert!(path.len() >= 2, "Path should have at least start and goal");
1897        let total_len = path.total_length();
1898        assert!(
1899            total_len < 30.0,
1900            "Path should be efficient, got length {}",
1901            total_len
1902        );
1903    }
1904
1905    #[test]
1906    fn test_jps_from_obstacle_points() {
1907        let (ox, oy) = create_small_obstacles();
1908        let obstacles = Obstacles::try_from_xy(&ox, &oy).unwrap();
1909        let planner = JPSPlanner::from_obstacle_points(&obstacles, JPSConfig::default()).unwrap();
1910
1911        let path = planner.plan_xy(2.0, 2.0, 8.0, 8.0).unwrap();
1912        assert!(!path.is_empty());
1913    }
1914
1915    #[test]
1916    fn test_jps_try_new_rejects_invalid_config() {
1917        let (ox, oy) = create_small_obstacles();
1918        let config = JPSConfig {
1919            heuristic_weight: 0.0,
1920            ..Default::default()
1921        };
1922
1923        let err = match JPSPlanner::try_new(&ox, &oy, config) {
1924            Ok(_) => panic!("expected invalid config to be rejected"),
1925            Err(err) => err,
1926        };
1927        assert!(matches!(err, RoboticsError::InvalidParameter(_)));
1928    }
1929
1930    #[test]
1931    fn test_jps_preserves_asymmetric_world_coordinates() {
1932        let mut obstacles = Obstacles::new();
1933
1934        for x in 10..=20 {
1935            obstacles.push(Point2D::new(x as f64, -4.0));
1936            obstacles.push(Point2D::new(x as f64, 6.0));
1937        }
1938        for y in -4..=6 {
1939            obstacles.push(Point2D::new(10.0, y as f64));
1940            obstacles.push(Point2D::new(20.0, y as f64));
1941        }
1942
1943        let planner = JPSPlanner::from_obstacle_points(&obstacles, JPSConfig::default()).unwrap();
1944        let path = planner.plan_xy(12.0, -2.0, 18.0, 4.0).unwrap();
1945
1946        let first = path.points.first().unwrap();
1947        let last = path.points.last().unwrap();
1948        assert!((first.x - 12.0).abs() < 1e-6);
1949        assert!((first.y + 2.0).abs() < 1e-6);
1950        assert!((last.x - 18.0).abs() < 1e-6);
1951        assert!((last.y - 4.0).abs() < 1e-6);
1952    }
1953
1954    #[test]
1955    fn test_jps_solves_upstream_jump_point_maze() {
1956        let (ox, oy) = create_upstream_jump_point_maze();
1957        let planner = JPSPlanner::from_obstacles(&ox, &oy, 1.0, 0.0);
1958
1959        let path = planner
1960            .plan(Point2D::new(5.0, 5.0), Point2D::new(35.0, 45.0))
1961            .unwrap();
1962
1963        let first = path.points.first().unwrap();
1964        let last = path.points.last().unwrap();
1965        assert!((first.x - 5.0).abs() < 1e-6);
1966        assert!((first.y - 5.0).abs() < 1e-6);
1967        assert!((last.x - 35.0).abs() < 1e-6);
1968        assert!((last.y - 45.0).abs() < 1e-6);
1969
1970        let upstream_reference_length = polyline_length(&upstream_jump_point_reference_polyline());
1971        assert!(
1972            path.total_length() <= upstream_reference_length + 1e-6,
1973            "Rust JPS path should stay no worse than PythonRobotics jump-point variant reference, got {} vs {}",
1974            path.total_length(),
1975            upstream_reference_length,
1976        );
1977
1978        let grid = planner.grid_map();
1979        for point in &path.points {
1980            let ix = grid.calc_x_index(point.x);
1981            let iy = grid.calc_y_index(point.y);
1982            assert!(
1983                grid.is_valid(ix, iy),
1984                "path point ({}, {}) should stay collision-free",
1985                point.x,
1986                point.y
1987            );
1988        }
1989    }
1990
1991    #[test]
1992    #[ignore = "long-running MovingAI benchmark"]
1993    fn test_jps_solves_moving_ai_arena2_bucket_80() {
1994        assert_pure_jps_matches_moving_ai_bucket(
1995            "arena2",
1996            include_str!("../benchdata/moving_ai/dao/arena2.map"),
1997            include_str!("../benchdata/moving_ai/dao/arena2.map.scen"),
1998            80,
1999        );
2000    }
2001
2002    #[test]
2003    #[ignore = "long-running MovingAI benchmark"]
2004    fn test_jps_reports_cost_mismatch_metrics_on_moving_ai_bucket_80_subset() {
2005        for (name, map_str, scen_str) in MOVING_AI_CASES {
2006            assert_pure_jps_matches_moving_ai_bucket(name, map_str, scen_str, 80);
2007        }
2008    }
2009
2010    #[test]
2011    #[ignore = "long-running MovingAI benchmark"]
2012    fn test_jps_solves_sampled_moving_ai_buckets_without_fallback() {
2013        const BUCKETS: [u32; 5] = [0, 20, 40, 60, 80];
2014
2015        for (name, map_str, scen_str) in MOVING_AI_CASES {
2016            for bucket in BUCKETS {
2017                assert_pure_jps_matches_moving_ai_bucket(name, map_str, scen_str, bucket);
2018            }
2019        }
2020    }
2021
2022    #[test]
2023    #[ignore = "long-running MovingAI benchmark"]
2024    fn test_jps_solves_long_tail_moving_ai_buckets_without_fallback() {
2025        let cases: [(&str, &str, &str, &[u32]); 3] = [
2026            (
2027                "arena2",
2028                include_str!("../benchdata/moving_ai/dao/arena2.map"),
2029                include_str!("../benchdata/moving_ai/dao/arena2.map.scen"),
2030                &[90],
2031            ),
2032            (
2033                "8room_000",
2034                include_str!("../benchdata/moving_ai/room/8room_000.map"),
2035                include_str!("../benchdata/moving_ai/room/8room_000.map.scen"),
2036                &[120, 160, 213],
2037            ),
2038            (
2039                "random512-10-0",
2040                include_str!("../benchdata/moving_ai/random/random512-10-0.map"),
2041                include_str!("../benchdata/moving_ai/random/random512-10-0.map.scen"),
2042                &[100, 140, 177],
2043            ),
2044        ];
2045
2046        for (name, map_str, scen_str, buckets) in cases {
2047            for bucket in buckets {
2048                assert_pure_jps_matches_moving_ai_bucket(name, map_str, scen_str, *bucket);
2049            }
2050        }
2051    }
2052
2053    #[test]
2054    #[ignore = "long-running MovingAI benchmark"]
2055    fn test_jps_solves_sampled_moving_ai_maze_buckets_without_fallback() {
2056        const BUCKETS: [u32; 6] = [0, 20, 40, 60, 80, 120];
2057
2058        for bucket in BUCKETS {
2059            assert_pure_jps_matches_moving_ai_bucket(
2060                "maze512-1-0",
2061                include_str!("../benchdata/moving_ai/maze/maze512-1-0.map"),
2062                include_str!("../benchdata/moving_ai/maze/maze512-1-0.map.scen"),
2063                bucket,
2064            );
2065        }
2066    }
2067
2068    #[test]
2069    #[ignore = "long-running MovingAI benchmark"]
2070    fn test_jps_solves_long_tail_moving_ai_maze_buckets_without_fallback() {
2071        const BUCKETS: [u32; 4] = [200, 400, 800, 1211];
2072
2073        for bucket in BUCKETS {
2074            assert_pure_jps_matches_moving_ai_bucket(
2075                "maze512-1-0",
2076                include_str!("../benchdata/moving_ai/maze/maze512-1-0.map"),
2077                include_str!("../benchdata/moving_ai/maze/maze512-1-0.map.scen"),
2078                bucket,
2079            );
2080        }
2081    }
2082
2083    #[test]
2084    #[ignore = "long-running MovingAI benchmark"]
2085    fn test_jps_solves_sampled_moving_ai_street_buckets_without_fallback() {
2086        const BUCKETS: [u32; 8] = [0, 20, 40, 60, 80, 120, 160, 186];
2087
2088        for bucket in BUCKETS {
2089            assert_pure_jps_matches_moving_ai_bucket(
2090                "Berlin_0_512",
2091                include_str!("../benchdata/moving_ai/street/Berlin_0_512.map"),
2092                include_str!("../benchdata/moving_ai/street/Berlin_0_512.map.scen"),
2093                bucket,
2094            );
2095        }
2096    }
2097
2098    #[test]
2099    #[ignore = "long-running MovingAI benchmark"]
2100    fn test_jps_vs_a_star_runtime_on_long_tail_moving_ai_subset() {
2101        const ITERATIONS: usize = 7;
2102        let cases: [(&str, &str, &str, u32); 5] = [
2103            (
2104                "arena2",
2105                include_str!("../benchdata/moving_ai/dao/arena2.map"),
2106                include_str!("../benchdata/moving_ai/dao/arena2.map.scen"),
2107                90,
2108            ),
2109            (
2110                "8room_000",
2111                include_str!("../benchdata/moving_ai/room/8room_000.map"),
2112                include_str!("../benchdata/moving_ai/room/8room_000.map.scen"),
2113                213,
2114            ),
2115            (
2116                "random512-10-0",
2117                include_str!("../benchdata/moving_ai/random/random512-10-0.map"),
2118                include_str!("../benchdata/moving_ai/random/random512-10-0.map.scen"),
2119                177,
2120            ),
2121            (
2122                "maze512-1-0",
2123                include_str!("../benchdata/moving_ai/maze/maze512-1-0.map"),
2124                include_str!("../benchdata/moving_ai/maze/maze512-1-0.map.scen"),
2125                1211,
2126            ),
2127            (
2128                "Berlin_0_512",
2129                include_str!("../benchdata/moving_ai/street/Berlin_0_512.map"),
2130                include_str!("../benchdata/moving_ai/street/Berlin_0_512.map.scen"),
2131                186,
2132            ),
2133        ];
2134
2135        for (name, map_str, scen_str, bucket) in cases {
2136            let (a_star, jps, start, goal, optimal_length) =
2137                build_moving_ai_bucket_planners(name, map_str, scen_str, bucket);
2138
2139            let warmup_a = a_star
2140                .plan(start, goal)
2141                .unwrap_or_else(|_| panic!("A* should solve {name} bucket {bucket}"));
2142            assert!(
2143                (warmup_a.total_length() - optimal_length).abs() <= 1e-6,
2144                "A* warmup path for {name} bucket {bucket} should match MovingAI optimal {}",
2145                optimal_length
2146            );
2147            let warmup_j = jps
2148                .plan(start, goal)
2149                .unwrap_or_else(|_| panic!("JPS should solve {name} bucket {bucket}"));
2150            assert!(
2151                (warmup_j.total_length() - optimal_length).abs() <= 1e-6,
2152                "JPS warmup path for {name} bucket {bucket} should match MovingAI optimal {}",
2153                optimal_length
2154            );
2155
2156            let a_star_median_us = measure_planner_median_us(ITERATIONS, || {
2157                let path = a_star
2158                    .plan(start, goal)
2159                    .unwrap_or_else(|_| panic!("A* should solve {name} bucket {bucket}"));
2160                assert!(
2161                    (path.total_length() - optimal_length).abs() <= 1e-6,
2162                    "A* path length {} should match MovingAI optimal {} on {name} bucket {bucket}",
2163                    path.total_length(),
2164                    optimal_length
2165                );
2166                path
2167            });
2168            let jps_median_us = measure_planner_median_us(ITERATIONS, || {
2169                let path = jps
2170                    .plan(start, goal)
2171                    .unwrap_or_else(|_| panic!("JPS should solve {name} bucket {bucket}"));
2172                assert!(
2173                    (path.total_length() - optimal_length).abs() <= 1e-6,
2174                    "JPS path length {} should match MovingAI optimal {} on {name} bucket {bucket}",
2175                    path.total_length(),
2176                    optimal_length
2177                );
2178                path
2179            });
2180            println!(
2181                "{name}/bucket{bucket}: a_star_median_us={:.2}, jps_median_us={:.2}, a_over_j={:.3}",
2182                a_star_median_us,
2183                jps_median_us,
2184                a_star_median_us / jps_median_us
2185            );
2186        }
2187    }
2188
2189    #[test]
2190    #[ignore = "long-running MovingAI benchmark"]
2191    fn test_jps_vs_a_star_runtime_crossover_samples_on_moving_ai() {
2192        const ITERATIONS: usize = 5;
2193        let families: [(&str, &str, &str, &[u32]); 5] = [
2194            (
2195                "arena2",
2196                include_str!("../benchdata/moving_ai/dao/arena2.map"),
2197                include_str!("../benchdata/moving_ai/dao/arena2.map.scen"),
2198                &[70, 75, 80, 85, 90],
2199            ),
2200            (
2201                "8room_000",
2202                include_str!("../benchdata/moving_ai/room/8room_000.map"),
2203                include_str!("../benchdata/moving_ai/room/8room_000.map.scen"),
2204                &[80, 120, 160, 213],
2205            ),
2206            (
2207                "random512-10-0",
2208                include_str!("../benchdata/moving_ai/random/random512-10-0.map"),
2209                include_str!("../benchdata/moving_ai/random/random512-10-0.map.scen"),
2210                &[80, 90, 100, 120, 140, 160, 177],
2211            ),
2212            (
2213                "maze512-1-0",
2214                include_str!("../benchdata/moving_ai/maze/maze512-1-0.map"),
2215                include_str!("../benchdata/moving_ai/maze/maze512-1-0.map.scen"),
2216                &[80, 120, 200, 400, 800, 1211],
2217            ),
2218            (
2219                "Berlin_0_512",
2220                include_str!("../benchdata/moving_ai/street/Berlin_0_512.map"),
2221                include_str!("../benchdata/moving_ai/street/Berlin_0_512.map.scen"),
2222                &[80, 120, 160, 186],
2223            ),
2224        ];
2225
2226        for (name, map_str, scen_str, buckets) in families {
2227            for bucket in buckets {
2228                let (a_star, jps, start, goal, optimal_length) =
2229                    build_moving_ai_bucket_planners(name, map_str, scen_str, *bucket);
2230
2231                let warmup_a = a_star
2232                    .plan(start, goal)
2233                    .unwrap_or_else(|_| panic!("A* should solve {name} bucket {bucket}"));
2234                assert!(
2235                    (warmup_a.total_length() - optimal_length).abs() <= 1e-6,
2236                    "A* warmup path for {name} bucket {bucket} should match MovingAI optimal {}",
2237                    optimal_length
2238                );
2239                let warmup_j = jps
2240                    .plan(start, goal)
2241                    .unwrap_or_else(|_| panic!("JPS should solve {name} bucket {bucket}"));
2242                assert!(
2243                    (warmup_j.total_length() - optimal_length).abs() <= 1e-6,
2244                    "JPS warmup path for {name} bucket {bucket} should match MovingAI optimal {}",
2245                    optimal_length
2246                );
2247
2248                let a_star_median_us = measure_planner_median_us(ITERATIONS, || {
2249                    let path = a_star
2250                        .plan(start, goal)
2251                        .unwrap_or_else(|_| panic!("A* should solve {name} bucket {bucket}"));
2252                    assert!(
2253                        (path.total_length() - optimal_length).abs() <= 1e-6,
2254                        "A* path length {} should match MovingAI optimal {} on {name} bucket {bucket}",
2255                        path.total_length(),
2256                        optimal_length
2257                    );
2258                    path
2259                });
2260                let jps_median_us = measure_planner_median_us(ITERATIONS, || {
2261                    let path = jps
2262                        .plan(start, goal)
2263                        .unwrap_or_else(|_| panic!("JPS should solve {name} bucket {bucket}"));
2264                    assert!(
2265                        (path.total_length() - optimal_length).abs() <= 1e-6,
2266                        "JPS path length {} should match MovingAI optimal {} on {name} bucket {bucket}",
2267                        path.total_length(),
2268                        optimal_length
2269                    );
2270                    path
2271                });
2272                println!(
2273                    "{name}/bucket{bucket}: a_star_median_us={:.2}, jps_median_us={:.2}, a_over_j={:.3}",
2274                    a_star_median_us,
2275                    jps_median_us,
2276                    a_star_median_us / jps_median_us
2277                );
2278            }
2279        }
2280    }
2281
2282    #[test]
2283    #[ignore = "long-running MovingAI benchmark"]
2284    fn test_jps_vs_a_star_runtime_bucket_aggregate_samples_on_moving_ai() {
2285        const ITERATIONS: usize = 1;
2286        const SAMPLE_SLOTS: [usize; 3] = [0, 4, 9];
2287        let families: [(&str, &str, &str, &[u32]); 5] = [
2288            (
2289                "arena2",
2290                include_str!("../benchdata/moving_ai/dao/arena2.map"),
2291                include_str!("../benchdata/moving_ai/dao/arena2.map.scen"),
2292                &[80, 81, 82, 83, 84, 85],
2293            ),
2294            (
2295                "8room_000",
2296                include_str!("../benchdata/moving_ai/room/8room_000.map"),
2297                include_str!("../benchdata/moving_ai/room/8room_000.map.scen"),
2298                &[80, 90, 100, 110, 120],
2299            ),
2300            (
2301                "random512-10-0",
2302                include_str!("../benchdata/moving_ai/random/random512-10-0.map"),
2303                include_str!("../benchdata/moving_ai/random/random512-10-0.map.scen"),
2304                &[120, 125, 130, 135, 140],
2305            ),
2306            (
2307                "maze512-1-0",
2308                include_str!("../benchdata/moving_ai/maze/maze512-1-0.map"),
2309                include_str!("../benchdata/moving_ai/maze/maze512-1-0.map.scen"),
2310                &[80, 200, 1211],
2311            ),
2312            (
2313                "Berlin_0_512",
2314                include_str!("../benchdata/moving_ai/street/Berlin_0_512.map"),
2315                include_str!("../benchdata/moving_ai/street/Berlin_0_512.map.scen"),
2316                &[80, 100, 120, 140, 160, 180, 186],
2317            ),
2318        ];
2319
2320        for (name, map_str, scen_str, buckets) in families {
2321            let (map, scenarios, a_star, jps) =
2322                build_moving_ai_family_planners(name, map_str, scen_str);
2323
2324            for bucket in buckets {
2325                let aggregate = measure_moving_ai_bucket_runtime_aggregate(
2326                    name,
2327                    &map,
2328                    &scenarios,
2329                    &a_star,
2330                    &jps,
2331                    *bucket,
2332                    &SAMPLE_SLOTS,
2333                    ITERATIONS,
2334                );
2335                println!(
2336                    "{name}/bucket{bucket}: sample_slots={:?}, a_star_bucket_median_us={:.2}, jps_bucket_median_us={:.2}, a_over_j={:.3}, jps_wins={}/{}",
2337                    SAMPLE_SLOTS,
2338                    aggregate.a_star_median_us,
2339                    aggregate.jps_median_us,
2340                    aggregate.a_star_median_us / aggregate.jps_median_us,
2341                    aggregate.jps_wins,
2342                    aggregate.sample_count
2343                );
2344            }
2345        }
2346    }
2347
2348    #[test]
2349    #[ignore = "long-running MovingAI benchmark"]
2350    fn test_jps_vs_a_star_runtime_bucket_full_aggregate_on_narrowed_windows() {
2351        const ITERATIONS: usize = 1;
2352        const FULL_BUCKET_SLOTS: [usize; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
2353        let families: [(&str, &str, &str, &[u32]); 4] = [
2354            (
2355                "arena2",
2356                include_str!("../benchdata/moving_ai/dao/arena2.map"),
2357                include_str!("../benchdata/moving_ai/dao/arena2.map.scen"),
2358                &[86, 87, 88, 89, 90],
2359            ),
2360            (
2361                "8room_000",
2362                include_str!("../benchdata/moving_ai/room/8room_000.map"),
2363                include_str!("../benchdata/moving_ai/room/8room_000.map.scen"),
2364                &[80, 85, 90],
2365            ),
2366            (
2367                "random512-10-0",
2368                include_str!("../benchdata/moving_ai/random/random512-10-0.map"),
2369                include_str!("../benchdata/moving_ai/random/random512-10-0.map.scen"),
2370                &[130, 132, 135],
2371            ),
2372            (
2373                "Berlin_0_512",
2374                include_str!("../benchdata/moving_ai/street/Berlin_0_512.map"),
2375                include_str!("../benchdata/moving_ai/street/Berlin_0_512.map.scen"),
2376                &[120, 140, 160, 186],
2377            ),
2378        ];
2379
2380        for (name, map_str, scen_str, buckets) in families {
2381            let (map, scenarios, a_star, jps) =
2382                build_moving_ai_family_planners(name, map_str, scen_str);
2383
2384            for bucket in buckets {
2385                let aggregate = measure_moving_ai_bucket_runtime_aggregate(
2386                    name,
2387                    &map,
2388                    &scenarios,
2389                    &a_star,
2390                    &jps,
2391                    *bucket,
2392                    &FULL_BUCKET_SLOTS,
2393                    ITERATIONS,
2394                );
2395                println!(
2396                    "{name}/bucket{bucket}: full_bucket_slots=0..9, a_star_bucket_median_us={:.2}, jps_bucket_median_us={:.2}, a_over_j={:.3}, a_star_range_us=[{:.2}, {:.2}], jps_range_us=[{:.2}, {:.2}], jps_wins={}/{}",
2397                    aggregate.a_star_median_us,
2398                    aggregate.jps_median_us,
2399                    aggregate.a_star_median_us / aggregate.jps_median_us,
2400                    aggregate.a_star_min_us,
2401                    aggregate.a_star_max_us,
2402                    aggregate.jps_min_us,
2403                    aggregate.jps_max_us,
2404                    aggregate.jps_wins,
2405                    aggregate.sample_count
2406                );
2407            }
2408        }
2409    }
2410}