Skip to main content

condor_grid/
search.rs

1//! Online 4-connected grid-search request, result, and [`Pathfinder`] contract.
2//!
3//! A caller supplies a [`SearchRequest`]; a solver returns [`GridSearchError`] for
4//! invalid endpoints or exhausted [`SearchBudget`], or [`SearchOutcome::Found`] /
5//! [`SearchOutcome::NoPath`] with [`SearchStats`] for a completed query. Costs and
6//! visit counts are algorithm-specific. Prefer [`crate::preprocessed_grid`] for a
7//! static build-once/query-many workflow, or [`crate::replanning`] when the grid
8//! changes between queries.
9
10use std::{error::Error, fmt};
11
12use crate::{grid::Grid, path::Path, point::Point};
13
14pub use condor_core::{
15    BudgetExhausted, BudgetWatch, SearchBudget, SearchOutcome, SearchPathCost, SearchVisitStats,
16};
17
18/// Start and goal cell endpoints for a single discrete grid search.
19///
20/// Walkability is checked at search time via [`validate_request`] / pathfinder code.
21/// Optional [`SearchBudget`] caps expansions and/or wall-clock time; default is
22/// unlimited.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct SearchRequest {
25    /// Inclusive path start cell.
26    pub start: Point,
27    /// Inclusive path goal cell.
28    pub goal: Point,
29    /// Optional expansion / wall-clock caps for this query (default unlimited).
30    pub budget: SearchBudget,
31}
32
33impl SearchRequest {
34    /// Pairs start and goal cells with an unlimited budget; walkability is checked at search time.
35    #[must_use]
36    pub const fn new(start: Point, goal: Point) -> Self {
37        Self {
38            start,
39            goal,
40            budget: SearchBudget::UNLIMITED,
41        }
42    }
43
44    /// Returns a copy of this request with the given budget.
45    #[must_use]
46    pub const fn with_budget(mut self, budget: SearchBudget) -> Self {
47        self.budget = budget;
48        self
49    }
50}
51
52/// Work counters produced by a grid search (algorithm-defined node visits).
53///
54/// `visited_nodes` is solver-local (cell expansions, jump stops, …); do not treat
55/// it as a portable complexity metric across algorithms.
56#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
57pub struct SearchStats {
58    /// Nodes the solver counts as expanded or settled (see algorithm docs).
59    pub visited_nodes: usize,
60}
61
62/// Failure to execute a grid search request (request validation or hard stop).
63///
64/// Unreachable but valid endpoints produce [`SearchOutcome::NoPath`], not these variants.
65/// Caller [`SearchBudget`] exhaustion is [`Self::BudgetExhausted`] and does **not**
66/// prove unreachability.
67#[non_exhaustive]
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum GridSearchError {
70    /// Start is out of bounds or not walkable.
71    InvalidStart { point: Point },
72    /// Goal is out of bounds or not walkable.
73    InvalidGoal { point: Point },
74    /// Caller search budget was exhausted before found/no-path completed.
75    BudgetExhausted(BudgetExhausted),
76    /// Flow-field path sampling stopped after `max_steps` steps without the goal.
77    ///
78    /// Not a general [`Pathfinder`] expansion budget API; only
79    /// [`crate::flow_field`] sampling emits this variant.
80    StepLimitReached { max_steps: usize, reached: Point },
81}
82
83impl fmt::Display for GridSearchError {
84    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
85        match self {
86            Self::InvalidStart { point } => write!(formatter, "invalid start point: {point:?}"),
87            Self::InvalidGoal { point } => write!(formatter, "invalid goal point: {point:?}"),
88            Self::BudgetExhausted(reason) => write!(formatter, "{reason}"),
89            Self::StepLimitReached { max_steps, reached } => write!(
90                formatter,
91                "search stopped after {max_steps} steps at {reached:?} before reaching the goal"
92            ),
93        }
94    }
95}
96
97impl Error for GridSearchError {}
98
99/// Grid search return type: validation error, or found/no-path with stats.
100pub type SearchResult = Result<SearchOutcome<Path, SearchStats>, GridSearchError>;
101
102/// Owner-crate helper: wraps a found path into [`SearchResult`] with visit stats.
103#[doc(hidden)]
104pub const fn found(path: Path, visited_nodes: usize) -> SearchResult {
105    Ok(SearchOutcome::found(path, SearchStats { visited_nodes }))
106}
107
108/// Owner-crate helper: wraps exhaustive no-path into [`SearchResult`] with visit stats.
109#[doc(hidden)]
110pub const fn not_found(visited_nodes: usize) -> SearchResult {
111    Ok(SearchOutcome::no_path(SearchStats { visited_nodes }))
112}
113
114/// Rejects out-of-bounds or blocked start/goal before search (validation `Err`, not no-path).
115#[doc(hidden)]
116pub fn validate_request(grid: &Grid, request: SearchRequest) -> Result<(), GridSearchError> {
117    if !grid.is_walkable(request.start) {
118        return Err(GridSearchError::InvalidStart {
119            point: request.start,
120        });
121    }
122    if !grid.is_walkable(request.goal) {
123        return Err(GridSearchError::InvalidGoal {
124            point: request.goal,
125        });
126    }
127    Ok(())
128}
129
130impl SearchPathCost for Path {
131    type Cost = usize;
132
133    fn path_cost(&self) -> Self::Cost {
134        self.cost()
135    }
136}
137
138impl SearchVisitStats for SearchStats {
139    fn visited_nodes(&self) -> usize {
140        self.visited_nodes
141    }
142}
143
144/// Online algorithm entrypoint for static 4-connected grid search.
145///
146/// Implementations must validate endpoints (walkable start/goal), return
147/// [`GridSearchError`] for invalid requests or exhausted [`SearchBudget`], and
148/// never panic on in-bounds grids. Unreachable but valid endpoints yield
149/// [`SearchOutcome::NoPath`], not an error. Cost models and heuristics are
150/// algorithm-defined (unit hop vs `traversal_cost`).
151pub trait Pathfinder {
152    /// Stable algorithm identifier for benchmarks, logs, and solver portfolios.
153    fn name(&self) -> &'static str;
154
155    /// Runs one search on `grid` for `request`.
156    ///
157    /// Returns `Err` for invalid endpoints or exhausted budgets, `Ok(NoPath)` when
158    /// exhaustive search finds no route, and `Ok(Found)` with a non-empty path and
159    /// stats when a route is found.
160    fn search(&self, grid: &Grid, request: SearchRequest) -> SearchResult;
161}
162
163/// Maps a shared budget failure into [`GridSearchError::BudgetExhausted`].
164#[doc(hidden)]
165pub const fn budget_error(reason: BudgetExhausted) -> GridSearchError {
166    GridSearchError::BudgetExhausted(reason)
167}