Skip to main content

condor_grid/algorithms/anya/
mod.rs

1//! Any-angle [`AnyAnglePathfinder`]: Anya-family entrypoint.
2//!
3//! [`Anya::search`] currently delegates to the exact corner-visibility-graph
4//! authority shared with the prepared any-angle lane. The row-interval candidate
5//! remains private and powers [`Anya::inspect`] diagnostics only; its successor
6//! kernel is not yet complete.
7//!
8//! Cost is Euclidean polyline length between grid-aligned vertices under the
9//! no-corner-cut LOS contract. Prefer Anya for the curated any-angle entrypoint,
10//! Theta* / Lazy Theta* for their direct online variants, and prepared any-angle
11//! for repeated-query amortization.
12
13mod diagnostics;
14mod geometry;
15#[allow(
16    dead_code,
17    reason = "private, not-ready candidate retained beside the ordinary Anya family; it may be discarded after evaluation"
18)]
19mod row_interval;
20mod runs;
21mod state;
22mod successors;
23
24use std::cell::Cell;
25use std::collections::{BinaryHeap, HashMap};
26
27pub use diagnostics::{AnyaDiagnostics, AnyaInspection};
28pub use geometry::IntervalKind;
29
30use crate::{
31    Grid,
32    algorithms::any_angle_visibility_graph::AnyAngleVisibilityGraphOracle,
33    any_angle::geometry::{approximately_equal, recompute_path_cost},
34    any_angle::{
35        AnyAnglePath, AnyAnglePathfinder, AnyAngleSearchRequest, AnyAngleSearchResult, found,
36        not_found,
37    },
38};
39use condor_core::Point2;
40
41use diagnostics::AnyaDiagnostics as Diagnostics;
42use geometry::{parse_request, segment_legal, validate_path};
43use runs::RowRunIndex;
44use state::{HeapEntry, IntervalState, StateArena, StateId};
45use successors::{GoalConnection, SuccessorContext, expand_state, initial_state, push_interval};
46
47/// Any-angle pathfinder implementing [`AnyAnglePathfinder`].
48///
49/// Public searches are oracle-supervised (exact corner visibility graph). Use
50/// [`Self::inspect`] for experimental interval-candidate diagnostics; do not treat
51/// inspect-only counters as product search stats.
52#[derive(Debug, Clone, Copy, Default)]
53pub struct Anya;
54
55impl Anya {
56    /// Search with solver-local diagnostics (not included in public stats).
57    ///
58    /// This explicitly evaluates the experimental interval candidate before
59    /// certifying it with the exact oracle. Use it for bounded diagnostic work,
60    /// not ordinary application searches.
61    #[must_use]
62    pub fn inspect(&self, grid: &Grid, request: AnyAngleSearchRequest) -> AnyaInspection {
63        let (result, diagnostics) = search_impl(grid, request, true);
64        AnyaInspection {
65            result,
66            diagnostics,
67        }
68    }
69}
70
71impl AnyAnglePathfinder for Anya {
72    fn name(&self) -> &'static str {
73        "anya"
74    }
75
76    fn search(&self, grid: &Grid, request: AnyAngleSearchRequest) -> AnyAngleSearchResult {
77        // The row-interval candidate is retained for `inspect`, but its
78        // successor kernel is not yet complete. Public calls have always
79        // returned this exact supervisor result; avoiding the candidate work
80        // here preserves that contract without its unbounded state growth.
81        AnyAngleVisibilityGraphOracle.search(grid, request)
82    }
83}
84
85fn search_impl(
86    grid: &Grid,
87    request: AnyAngleSearchRequest,
88    collect_diagnostics: bool,
89) -> (AnyAngleSearchResult, Diagnostics) {
90    let mut diagnostics = Diagnostics::default();
91
92    let (start, goal) = match parse_request(grid, request.start, request.goal) {
93        Ok(endpoints) => endpoints,
94        Err(error) => return (Err(error), diagnostics),
95    };
96
97    if approximately_equal(start.x, goal.x) && approximately_equal(start.y, goal.y) {
98        let path = AnyAnglePath::from_points(vec![start, goal]).expect("non-empty path");
99        return (found(path, 1), diagnostics);
100    }
101
102    if segment_legal(grid, start, goal) {
103        let path = AnyAnglePath::from_points(vec![start, goal]).expect("non-empty path");
104        diagnostics.path_points = 2;
105        return (found(path, 1), diagnostics);
106    }
107
108    let runs = RowRunIndex::build(grid);
109    diagnostics.run_index_bytes = runs.bytes();
110
111    let Some(start_run) = runs.run_containing(start) else {
112        return (not_found(0), diagnostics);
113    };
114
115    let mut arena = StateArena::default();
116    let mut best_by_interval = HashMap::new();
117    let mut heap = BinaryHeap::new();
118    let best_goal_cost = Cell::new(f64::INFINITY);
119    let goal_connection = Cell::new(None::<GoalConnection>);
120    let mut visited_nodes = 0usize;
121
122    let initial = initial_state(start, start_run);
123    let initial_enqueued = enqueue_interval(
124        grid,
125        &runs,
126        goal,
127        &mut arena,
128        &mut best_by_interval,
129        &mut diagnostics,
130        &best_goal_cost,
131        &goal_connection,
132        StateId(0),
133        initial,
134    );
135    for entry in initial_enqueued {
136        heap.push(entry);
137    }
138    diagnostics.heap_peak = heap.len();
139
140    while let Some(entry) = heap.pop() {
141        diagnostics.popped += 1;
142        let state_id = entry.state_id;
143        let state = *arena.get(state_id);
144
145        if arena.generation(state_id) != state.generation {
146            diagnostics.stale += 1;
147            continue;
148        }
149
150        visited_nodes += 1;
151
152        // Interval keys are an expansion order only.  A state may contain a
153        // legal goal connection through a point not represented by its endpoint
154        // lower bound, so do not use the key as a proof that the incumbent is
155        // globally final.
156
157        let enqueued = expand_and_enqueue(
158            grid,
159            &runs,
160            goal,
161            &mut arena,
162            &mut best_by_interval,
163            &mut diagnostics,
164            &best_goal_cost,
165            &goal_connection,
166            state_id,
167            state,
168        );
169        for queued in enqueued {
170            heap.push(queued);
171        }
172        diagnostics.heap_peak = diagnostics.heap_peak.max(heap.len());
173    }
174
175    let interval_result = if let Some(goal_connection) = goal_connection.get() {
176        let points = reconstruct_path(grid, &arena, goal_connection, start, goal);
177        diagnostics.path_points = points.len();
178        diagnostics.validation_segments = points.len().saturating_sub(1);
179
180        if validate_path(grid, &points) {
181            let cost = recompute_path_cost(&points);
182            let path = AnyAnglePath::from_points_with_cost(points, cost).expect("non-empty path");
183            found(path, visited_nodes)
184        } else {
185            not_found(visited_nodes)
186        }
187    } else {
188        not_found(visited_nodes)
189    };
190
191    if collect_diagnostics {
192        diagnostics.state_bytes = arena.bytes();
193    }
194
195    // The interval engine is intentionally retained as an independently
196    // instrumented candidate, but its successor completeness proof is still
197    // open.  Certify every non-trivial public result with the exact v0 oracle
198    // rather than returning a potentially longer interval witness.
199    diagnostics.exact_supervisor_queries = 1;
200    let (exact_result, _) = AnyAngleVisibilityGraphOracle
201        .search_with_diagnostics(grid, AnyAngleSearchRequest::new(start, goal));
202    if !same_outcome_cost(&interval_result, &exact_result) {
203        diagnostics.exact_supervisor_replacements = 1;
204    }
205    (exact_result, diagnostics)
206}
207
208fn same_outcome_cost(left: &AnyAngleSearchResult, right: &AnyAngleSearchResult) -> bool {
209    match (left, right) {
210        (Ok(left), Ok(right)) => match (left.path(), right.path()) {
211            (None, None) => true,
212            (Some(left), Some(right)) => approximately_equal(left.cost(), right.cost()),
213            _ => false,
214        },
215        (Err(left), Err(right)) => left == right,
216        _ => false,
217    }
218}
219
220#[allow(clippy::too_many_arguments)]
221fn enqueue_interval(
222    grid: &Grid,
223    runs: &RowRunIndex,
224    goal: Point2,
225    arena: &mut StateArena,
226    best_by_interval: &mut HashMap<successors::DominanceKey, (f64, StateId)>,
227    diagnostics: &mut Diagnostics,
228    best_goal_cost: &Cell<f64>,
229    goal_connection: &Cell<Option<GoalConnection>>,
230    predecessor: StateId,
231    state: IntervalState,
232) -> Vec<HeapEntry> {
233    let mut pending = Vec::new();
234    let mut ctx = SuccessorContext {
235        grid,
236        runs,
237        goal,
238        arena,
239        best_by_interval,
240        diagnostics,
241        best_goal_cost,
242        goal_connection,
243        pending_heap: &mut pending,
244    };
245    push_interval(&mut ctx, predecessor, state);
246    pending
247}
248
249#[allow(clippy::too_many_arguments)]
250fn expand_and_enqueue(
251    grid: &Grid,
252    runs: &RowRunIndex,
253    goal: Point2,
254    arena: &mut StateArena,
255    best_by_interval: &mut HashMap<successors::DominanceKey, (f64, StateId)>,
256    diagnostics: &mut Diagnostics,
257    best_goal_cost: &Cell<f64>,
258    goal_connection: &Cell<Option<GoalConnection>>,
259    state_id: StateId,
260    state: IntervalState,
261) -> Vec<HeapEntry> {
262    let mut pending = Vec::new();
263    let mut ctx = SuccessorContext {
264        grid,
265        runs,
266        goal,
267        arena,
268        best_by_interval,
269        diagnostics,
270        best_goal_cost,
271        goal_connection,
272        pending_heap: &mut pending,
273    };
274    expand_state(&mut ctx, state_id, state);
275    pending
276}
277
278fn reconstruct_path(
279    grid: &Grid,
280    arena: &StateArena,
281    goal_connection: GoalConnection,
282    start: Point2,
283    goal: Point2,
284) -> Vec<Point2> {
285    let mut points = vec![goal];
286    let goal_id = goal_connection.terminal_state;
287    if let Some(probe) = goal_connection.via
288        && (!approximately_equal(probe.x, goal.x) || !approximately_equal(probe.y, goal.y))
289    {
290        points.push(probe);
291    }
292
293    let mut current = Some(goal_id);
294    while let Some(id) = current {
295        let state = arena.get(id);
296        if points.last().is_none_or(|last| {
297            !approximately_equal(last.x, state.root.x) || !approximately_equal(last.y, state.root.y)
298        }) {
299            points.push(state.root);
300        }
301        current = state.predecessor;
302    }
303    if points.last().is_none_or(|last| {
304        !approximately_equal(last.x, start.x) || !approximately_equal(last.y, start.y)
305    }) {
306        points.push(start);
307    }
308    points.reverse();
309    simplify_collinear(grid, &mut points);
310    points
311}
312
313fn simplify_collinear(grid: &Grid, points: &mut Vec<Point2>) {
314    if points.len() < 3 {
315        return;
316    }
317    let mut simplified = Vec::with_capacity(points.len());
318    simplified.push(points[0]);
319    for idx in 1..points.len() - 1 {
320        let prev = simplified[simplified.len() - 1];
321        let current = points[idx];
322        let next = points[idx + 1];
323        if are_collinear(prev, current, next) && segment_legal(grid, prev, next) {
324            continue;
325        }
326        simplified.push(current);
327    }
328    simplified.push(*points.last().expect("non-empty"));
329    *points = simplified;
330}
331
332fn are_collinear(a: Point2, b: Point2, c: Point2) -> bool {
333    let abx = b.x - a.x;
334    let aby = b.y - a.y;
335    let bcx = c.x - b.x;
336    let bcy = c.y - b.y;
337    (abx * bcy - aby * bcx).abs() <= 1e-12
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use crate::{grid::Cell, point::Point};
344
345    #[test]
346    fn legality_aware_collinear_simplification_retains_checkerboard_corners() {
347        let mut points = vec![
348            Point2::new(0.0, 0.0),
349            Point2::new(1.0, 1.0),
350            Point2::new(2.0, 2.0),
351            Point2::new(3.0, 3.0),
352            Point2::new(4.0, 4.0),
353        ];
354        let mut grid = Grid::new(5, 5).expect("grid");
355        for (x, y) in [
356            (1, 0),
357            (3, 0),
358            (0, 1),
359            (2, 1),
360            (4, 1),
361            (1, 2),
362            (3, 2),
363            (0, 3),
364            (2, 3),
365            (4, 3),
366            (1, 4),
367            (3, 4),
368        ] {
369            grid.set_cell(Point::new(x, y), Cell::Blocked)
370                .expect("block");
371        }
372        simplify_collinear(&grid, &mut points);
373        assert_eq!(
374            points.len(),
375            5,
376            "must retain corner waypoints, got {points:?}"
377        );
378    }
379
380    #[test]
381    fn forbidden_pinch_matches_oracle_cost() {
382        use crate::{
383            algorithms::any_angle_visibility_graph::AnyAngleVisibilityGraphOracle,
384            any_angle::geometry::approximately_equal,
385        };
386
387        let mut grid = Grid::new(4, 4).expect("grid");
388        for point in [Point::new(1, 1), Point::new(2, 2)] {
389            grid.set_cell(point, Cell::Blocked).expect("block");
390        }
391        let request = AnyAngleSearchRequest::new(Point2::new(0.0, 0.0), Point2::new(3.0, 3.0));
392        let oracle = AnyAngleVisibilityGraphOracle
393            .search(&grid, request)
394            .expect("valid");
395        let result = Anya.search(&grid, request).expect("valid");
396
397        assert!(result.is_found());
398        let oracle_cost = oracle.path().expect("oracle").cost();
399        let anya_cost = result.path().expect("anya").cost();
400        assert!(approximately_equal(anya_cost, oracle_cost));
401    }
402
403    #[test]
404    fn fully_blocked_grid_matches_oracle_reachability() {
405        use crate::algorithms::any_angle_visibility_graph::AnyAngleVisibilityGraphOracle;
406        let mut grid = Grid::new(2, 2).expect("grid");
407        for x in 0..2 {
408            for y in 0..2 {
409                grid.set_cell(Point::new(x, y), Cell::Blocked)
410                    .expect("block");
411            }
412        }
413        let request = AnyAngleSearchRequest::new(Point2::new(0.0, 0.0), Point2::new(2.0, 2.0));
414        let oracle = AnyAngleVisibilityGraphOracle
415            .search(&grid, request)
416            .expect("valid");
417        let result = Anya.search(&grid, request).expect("valid");
418        assert_eq!(
419            result.is_found(),
420            oracle.is_found(),
421            "anya/oracle reachability mismatch on fully blocked 2x2 (anya path={:?})",
422            result.path().map(|p| p.points().to_vec())
423        );
424    }
425}