Skip to main content

condor_grid/algorithms/
bfs.rs

1//! Static-grid [`Pathfinder`]: unweighted 4-connected BFS.
2//!
3//! Each search is independent and returns the standard invalid/found/no-path outcome.
4//! Cost is one hop per cardinal edge; [`Grid::traversal_cost`](crate::Grid::traversal_cost)
5//! is ignored, so BFS is optimal only when hop count is the intended metric. Prefer
6//! [`super::astar::AStar`] or [`super::dijkstra::Dijkstra`] when cell costs vary.
7
8use std::collections::VecDeque;
9
10use crate::{
11    grid::Grid,
12    path::Path,
13    search::{BudgetWatch, Pathfinder, SearchRequest, SearchResult},
14};
15
16/// Online [`Pathfinder`]: unweighted 4-connected BFS.
17///
18/// Cost model: unit hop count; ignores `traversal_cost`. Optimal when hop cost is the
19/// metric. Prefer for unweighted connectivity; use A*/Dijkstra when cell costs vary.
20#[derive(Debug, Default, Clone, Copy)]
21pub struct Bfs;
22
23impl Pathfinder for Bfs {
24    fn name(&self) -> &'static str {
25        "bfs"
26    }
27
28    fn search(&self, grid: &Grid, request: SearchRequest) -> SearchResult {
29        crate::search::validate_request(grid, request)?;
30        let Some(start_index) = grid.index_of(request.start) else {
31            return crate::search::not_found(0);
32        };
33        let Some(goal_index) = grid.index_of(request.goal) else {
34            return crate::search::not_found(0);
35        };
36
37        if !grid.is_walkable(request.start) || !grid.is_walkable(request.goal) {
38            return crate::search::not_found(0);
39        }
40
41        if !grid.is_reachable(request.start, request.goal) {
42            return crate::search::not_found(0);
43        }
44
45        if request.start == request.goal {
46            return crate::search::found(
47                Path::from_steps(vec![request.start]).expect("path contains at least one point"),
48                1,
49            );
50        }
51
52        let mut frontier = VecDeque::from([start_index]);
53        let mut discovered = vec![false; grid.cell_count()];
54        let mut parents = vec![None; grid.cell_count()];
55        let mut visited_nodes = 0;
56        let watch = BudgetWatch::start(request.budget);
57
58        discovered[start_index] = true;
59
60        while let Some(current_index) = frontier.pop_front() {
61            visited_nodes += 1;
62
63            if current_index == goal_index {
64                break;
65            }
66
67            if let Err(reason) = watch.check(visited_nodes) {
68                return Err(crate::search::budget_error(reason));
69            }
70
71            let current = grid.point_from_index(current_index);
72            for neighbor in grid.neighbors4(current) {
73                let neighbor_index = grid
74                    .index_of(neighbor)
75                    .expect("walkable neighbors must exist inside the grid");
76
77                if discovered[neighbor_index] {
78                    continue;
79                }
80
81                discovered[neighbor_index] = true;
82                parents[neighbor_index] = Some(current_index);
83                frontier.push_back(neighbor_index);
84            }
85        }
86
87        if !discovered[goal_index] {
88            return crate::search::not_found(visited_nodes);
89        }
90
91        crate::search::found(
92            reconstruct_path(grid, &parents, start_index, goal_index),
93            visited_nodes,
94        )
95    }
96}
97
98fn reconstruct_path(
99    grid: &Grid,
100    parents: &[Option<usize>],
101    start_index: usize,
102    goal_index: usize,
103) -> Path {
104    let mut current_index = goal_index;
105    let mut steps = vec![grid.point_from_index(goal_index)];
106
107    while current_index != start_index {
108        current_index =
109            parents[current_index].expect("a discovered goal must have a complete parent chain");
110        steps.push(grid.point_from_index(current_index));
111    }
112
113    steps.reverse();
114    Path::from_steps(steps).expect("path contains at least one point")
115}
116
117#[cfg(test)]
118mod tests {
119    use crate::{
120        algorithms::bfs::Bfs,
121        grid::{Cell, Grid},
122        point::Point,
123        search::{BudgetExhausted, GridSearchError, Pathfinder, SearchBudget, SearchRequest},
124    };
125
126    #[test]
127    fn expansion_budget_stops_before_goal() {
128        let grid = Grid::new(5, 1).expect("grid dimensions are valid");
129        let request = SearchRequest::new(Point::new(0, 0), Point::new(4, 0))
130            .with_budget(SearchBudget::max_expansions(2));
131        let error = Bfs
132            .search(&grid, request)
133            .expect_err("budget should exhaust on a long corridor");
134        assert_eq!(
135            error,
136            GridSearchError::BudgetExhausted(BudgetExhausted::Expansions {
137                limit: 2,
138                expansions: 2
139            })
140        );
141    }
142
143    #[test]
144    fn unlimited_budget_finds_path() {
145        let grid = Grid::new(5, 1).expect("grid dimensions are valid");
146        let request = SearchRequest::new(Point::new(0, 0), Point::new(4, 0));
147        let result = Bfs.search(&grid, request).expect("request is valid");
148        assert!(result.is_found());
149    }
150
151    #[test]
152    fn finds_a_shortest_path_through_the_only_gap() {
153        let mut grid = Grid::new(5, 5).expect("grid dimensions are valid");
154        for y in 0..5 {
155            if y != 2 {
156                grid.set_cell(Point::new(2, y), Cell::Blocked)
157                    .expect("valid grid edit");
158            }
159        }
160
161        let bfs = Bfs;
162        let result = bfs.search(
163            &grid,
164            SearchRequest::new(Point::new(0, 0), Point::new(4, 4)),
165        );
166
167        assert!(result.as_ref().expect("valid search request").is_found());
168        assert_eq!(
169            result.as_ref().expect("valid search request").cost(),
170            Some(8)
171        );
172
173        let path = result
174            .as_ref()
175            .expect("valid search request")
176            .path()
177            .expect("path should exist");
178        assert_eq!(path.start(), Point::new(0, 0));
179        assert_eq!(path.goal(), Point::new(4, 4));
180        assert!(path.steps().contains(&Point::new(2, 2)));
181    }
182
183    #[test]
184    fn reports_when_no_path_exists() {
185        let mut grid = Grid::new(3, 3).expect("grid dimensions are valid");
186        for x in 0..3 {
187            grid.set_cell(Point::new(x, 1), Cell::Blocked)
188                .expect("valid grid edit");
189        }
190
191        let bfs = Bfs;
192        let result = bfs.search(
193            &grid,
194            SearchRequest::new(Point::new(0, 0), Point::new(2, 2)),
195        );
196
197        assert!(!result.as_ref().expect("valid search request").is_found());
198        assert_eq!(result.as_ref().expect("valid search request").cost(), None);
199        assert!(
200            result
201                .as_ref()
202                .expect("valid search request")
203                .stats()
204                .visited_nodes
205                > 0
206        );
207    }
208}