Skip to main content

condor_grid/
prepared_any_angle.rs

1//! Prepared exact any-angle search for many queries over one static grid.
2//!
3//! [`PreparedAnyAngleGridBuilder`] snapshots retained visibility vertices and their
4//! Euclidean LOS edges into CSR form. Each [`PreparedAnyAngleGrid::search`] overlays
5//! endpoints without mutating that snapshot and preserves the any-angle invalid/
6//! found/no-path contract. Prefer this lane over [`crate::ThetaStar`] or
7//! [`crate::Anya`] only when its explicit node, edge, and retained-byte budgets admit
8//! a repeated-query workload.
9
10use crate::{
11    Grid,
12    algorithms::any_angle_visibility_graph_kernel::{
13        DijkstraSearchStats, VisibilityGraphBuildStats, VisibilityGraphCsr, add_undirected_edge,
14        build_visibility_csr, elide_collinear_points, node_index, path_endpoints_match_request,
15        run_dijkstra_csr_with_overlay,
16    },
17    any_angle::geometry::{
18        approximately_equal, canonicalize_grid_vertex, extract_boundary_edges, is_endpoint_valid,
19        retained_visibility_vertices, segment_is_legal, validated_any_angle_path,
20    },
21    any_angle::{
22        AnyAngleSearchError, AnyAngleSearchRequest, AnyAngleSearchResult, AnyAngleSearchStats,
23    },
24    search::SearchOutcome,
25};
26use condor_core::Point2;
27
28/// Maximum prepared visibility nodes supported by the v0 repeated-query lane.
29///
30/// Open 64×64 grids retain 4,225 vertices and remain admitted. Open 128×128
31/// retains 16,641 vertices and is rejected before pair enumeration.
32pub const PREPARED_ANY_ANGLE_NODE_BUDGET: usize = 10_000;
33
34/// Conservative upper bound on possible directed visibility edges.
35///
36/// Uses the all-pairs-visible estimate `n·(n−1)` so dense open fields are
37/// rejected before quadratic visibility work begins.
38pub const PREPARED_ANY_ANGLE_DIRECTED_EDGE_BUDGET: usize = 32_000_000;
39
40/// Conservative upper bound on preprocess-retained bytes (vertices + CSR).
41pub const PREPARED_ANY_ANGLE_RETAINED_BYTES_BUDGET: usize = 256_000_000;
42
43/// Maximum CSR neighbor/weight index storable in `u32`.
44pub const PREPARED_ANY_ANGLE_CSR_U32_INDEX_LIMIT: u32 = u32::MAX;
45
46/// Maximum retained-node count measured by the default prepared-v2 Criterion lane.
47///
48/// This is deliberately stricter than [`PREPARED_ANY_ANGLE_NODE_BUDGET`]. A graph
49/// can be safe to build for explicit evidence while still being too large for a
50/// default Criterion invocation to repeatedly preprocess without consuming an
51/// unreasonable amount of memory or time.
52pub const PREPARED_ANY_ANGLE_DEFAULT_BENCHMARK_NODE_BUDGET: usize = 2_000;
53
54/// Preprocesses a static grid into an exact any-angle visibility-graph snapshot.
55///
56/// Fails closed when retained vertices or projected edges exceed the published
57/// budgets (no partial CSR is returned).
58#[derive(Debug, Clone, Copy, Default)]
59pub struct PreparedAnyAngleGridBuilder;
60
61impl PreparedAnyAngleGridBuilder {
62    /// Constructs the default prepared any-angle builder.
63    #[must_use]
64    pub const fn new() -> Self {
65        Self
66    }
67
68    /// Stable builder identity for reports and benchmarks.
69    #[must_use]
70    pub const fn name(&self) -> &'static str {
71        "prepared-any-angle-grid"
72    }
73
74    /// Snapshots `grid` into a prepared visibility graph.
75    ///
76    /// # Errors
77    ///
78    /// Returns [`PreparedAnyAngleGridBuildError`] when conservative preflight
79    /// limits are exceeded.
80    pub fn preprocess(
81        &self,
82        grid: &Grid,
83    ) -> Result<PreparedAnyAngleGrid, PreparedAnyAngleGridBuildError> {
84        PreparedAnyAngleGrid::build(grid)
85    }
86
87    /// Classifies whether `grid` belongs in the default prepared-v2 benchmark lane.
88    ///
89    /// This performs only retained-vertex discovery and conservative preflight; it
90    /// never enumerates visibility pairs or allocates the prepared CSR.
91    #[must_use]
92    pub fn default_benchmark_admission(&self, grid: &Grid) -> PreparedAnyAngleBenchmarkAdmission {
93        let node_count = retained_visibility_vertices(grid).len();
94        match preflight_build_limits(node_count) {
95            Err(error) => PreparedAnyAngleBenchmarkAdmission::Unsupported { error },
96            Ok(()) if node_count > PREPARED_ANY_ANGLE_DEFAULT_BENCHMARK_NODE_BUDGET => {
97                PreparedAnyAngleBenchmarkAdmission::DeferredHeavy { node_count }
98            }
99            Ok(()) => PreparedAnyAngleBenchmarkAdmission::Measure { node_count },
100        }
101    }
102}
103
104/// Immutable prepared visibility graph for repeated exact any-angle queries.
105///
106/// Holds a grid snapshot, retained vertices, and CSR edges from preprocess. Queries
107/// never mutate durable state; start/goal are attached via local overlay edges.
108#[derive(Debug, Clone, PartialEq)]
109pub struct PreparedAnyAngleGrid {
110    grid: Grid,
111    nodes: Vec<Point2>,
112    csr: VisibilityGraphCsr,
113    build_diagnostics: PreparedAnyAngleBuildDiagnostics,
114}
115
116impl PreparedAnyAngleGrid {
117    /// Returns the matching prepared builder.
118    #[must_use]
119    pub fn builder() -> PreparedAnyAngleGridBuilder {
120        PreparedAnyAngleGridBuilder::new()
121    }
122
123    /// Stable prepared-map identity.
124    #[must_use]
125    pub fn name(&self) -> &'static str {
126        PreparedAnyAngleGridBuilder::new().name()
127    }
128
129    /// Owned immutable grid snapshot used for legality and endpoint validation.
130    #[must_use]
131    pub fn grid(&self) -> &Grid {
132        &self.grid
133    }
134
135    /// Retained visibility-graph vertices in deterministic preprocess order.
136    #[must_use]
137    pub fn nodes(&self) -> &[Point2] {
138        &self.nodes
139    }
140
141    /// Build-time diagnostics recorded during preprocess.
142    #[must_use]
143    pub fn build_diagnostics(&self) -> &PreparedAnyAngleBuildDiagnostics {
144        &self.build_diagnostics
145    }
146
147    /// Runs one exact prepared search.
148    pub fn search(&self, request: AnyAngleSearchRequest) -> AnyAngleSearchResult {
149        self.search_with_diagnostics(request).0
150    }
151
152    /// Runs one exact prepared search and returns query diagnostics.
153    pub fn search_with_diagnostics(
154        &self,
155        request: AnyAngleSearchRequest,
156    ) -> (AnyAngleSearchResult, PreparedAnyAngleQueryDiagnostics) {
157        let Some(start) = canonicalize_grid_vertex(request.start) else {
158            return (
159                Err(AnyAngleSearchError::InvalidStart {
160                    point: request.start,
161                }),
162                PreparedAnyAngleQueryDiagnostics::default(),
163            );
164        };
165        let Some(goal) = canonicalize_grid_vertex(request.goal) else {
166            return (
167                Err(AnyAngleSearchError::InvalidGoal {
168                    point: request.goal,
169                }),
170                PreparedAnyAngleQueryDiagnostics::default(),
171            );
172        };
173        if !is_endpoint_valid(&self.grid, start) {
174            return (
175                Err(AnyAngleSearchError::InvalidStart {
176                    point: request.start,
177                }),
178                PreparedAnyAngleQueryDiagnostics::default(),
179            );
180        }
181        if !is_endpoint_valid(&self.grid, goal) {
182            return (
183                Err(AnyAngleSearchError::InvalidGoal {
184                    point: request.goal,
185                }),
186                PreparedAnyAngleQueryDiagnostics::default(),
187            );
188        }
189        let request = AnyAngleSearchRequest::new(start, goal);
190
191        if approximately_equal(request.start.x, request.goal.x)
192            && approximately_equal(request.start.y, request.goal.y)
193        {
194            let path = validated_any_angle_path(&self.grid, vec![request.start, request.goal])
195                .expect("start equals goal path is non-empty");
196            return (
197                Ok(SearchOutcome::found(
198                    path,
199                    AnyAngleSearchStats { visited_nodes: 1 },
200                )),
201                PreparedAnyAngleQueryDiagnostics {
202                    path_point_count: 2,
203                    ..PreparedAnyAngleQueryDiagnostics::default()
204                },
205            );
206        }
207
208        let mut query_diagnostics = PreparedAnyAngleQueryDiagnostics {
209            direct_start_goal_tested: true,
210            ..Default::default()
211        };
212        query_diagnostics.endpoint_visibility_tests += 1;
213        if segment_is_legal(&self.grid, request.start, request.goal) {
214            query_diagnostics.direct_start_goal_visible = true;
215            let path = validated_any_angle_path(&self.grid, vec![request.start, request.goal])
216                .expect("direct visible segment should validate");
217            return (
218                Ok(SearchOutcome::found(
219                    path,
220                    AnyAngleSearchStats { visited_nodes: 1 },
221                )),
222                PreparedAnyAngleQueryDiagnostics {
223                    path_point_count: 2,
224                    ..query_diagnostics
225                },
226            );
227        }
228
229        let (supplemental_nodes, overlay_edges, start_index, goal_index, overlay_stats) =
230            build_overlay_search_graph(&self.grid, &self.nodes, request.start, request.goal);
231        query_diagnostics.endpoint_visibility_tests += overlay_stats.endpoint_visibility_tests;
232        query_diagnostics.endpoint_accepted_edges += overlay_stats.endpoint_accepted_edges;
233
234        let mut search_stats = DijkstraSearchStats::default();
235        let watch = crate::search::BudgetWatch::start(request.budget);
236        let search = match run_dijkstra_csr_with_overlay(
237            &self.csr,
238            self.nodes.len(),
239            &overlay_edges,
240            start_index,
241            goal_index,
242            &mut search_stats,
243            &watch,
244        ) {
245            Ok(outcome) => outcome,
246            Err(reason) => {
247                return (
248                    Err(crate::any_angle::budget_error(reason)),
249                    query_diagnostics,
250                );
251            }
252        };
253        query_diagnostics.settled_nodes = search_stats.settled_nodes;
254        query_diagnostics.pushes = search_stats.pushes;
255        query_diagnostics.stale_pops = search_stats.stale_pops;
256
257        let Some(predecessors) = search else {
258            return (
259                Ok(SearchOutcome::no_path(AnyAngleSearchStats {
260                    visited_nodes: query_diagnostics.settled_nodes,
261                })),
262                query_diagnostics,
263            );
264        };
265
266        let mut path_points =
267            reconstruct_prepared_path(&self.nodes, &supplemental_nodes, &predecessors, goal_index);
268        elide_collinear_points(&self.grid, &mut path_points);
269        query_diagnostics.path_point_count = path_points.len();
270
271        let Ok(path) = validated_any_angle_path(&self.grid, path_points) else {
272            return (
273                Ok(SearchOutcome::no_path(AnyAngleSearchStats {
274                    visited_nodes: query_diagnostics.settled_nodes,
275                })),
276                query_diagnostics,
277            );
278        };
279        if !path_endpoints_match_request(&path, request) {
280            return (
281                Ok(SearchOutcome::no_path(AnyAngleSearchStats {
282                    visited_nodes: query_diagnostics.settled_nodes,
283                })),
284                query_diagnostics,
285            );
286        }
287
288        (
289            Ok(SearchOutcome::found(
290                path,
291                AnyAngleSearchStats {
292                    visited_nodes: query_diagnostics.settled_nodes,
293                },
294            )),
295            query_diagnostics,
296        )
297    }
298
299    fn build(grid: &Grid) -> Result<Self, PreparedAnyAngleGridBuildError> {
300        let boundary_edges = extract_boundary_edges(grid).len();
301        let nodes = retained_visibility_vertices(grid);
302        preflight_build_limits(nodes.len())?;
303
304        let mut build_stats = VisibilityGraphBuildStats::default();
305        let csr = build_visibility_csr(grid, &nodes, &mut build_stats);
306        let node_bytes = nodes.len() * size_of::<Point2>();
307        let build_diagnostics = PreparedAnyAngleBuildDiagnostics {
308            boundary_edges,
309            prepared_nodes: nodes.len(),
310            directed_edges: csr.directed_edge_count(),
311            visibility_checks: build_stats.visibility_checks,
312            accepted_undirected_edges: build_stats.accepted_undirected_edges,
313            retained_bytes: node_bytes + csr.retained_bytes(),
314        };
315
316        Ok(Self {
317            grid: grid.clone(),
318            nodes,
319            csr,
320            build_diagnostics,
321        })
322    }
323}
324
325/// Default-measurement classification for the prepared-v2 Criterion lane.
326#[derive(Debug, Clone, Copy, PartialEq, Eq)]
327pub enum PreparedAnyAngleBenchmarkAdmission {
328    /// The scenario is within both the safe preprocess and default-measurement budgets.
329    Measure { node_count: usize },
330    /// The scenario is safe to build but intentionally omitted from default measurements.
331    DeferredHeavy { node_count: usize },
332    /// The scenario is rejected by the normal prepared-map preflight policy.
333    Unsupported {
334        error: PreparedAnyAngleGridBuildError,
335    },
336}
337
338/// Build-time diagnostics for a prepared any-angle visibility graph.
339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
340pub struct PreparedAnyAngleBuildDiagnostics {
341    pub boundary_edges: usize,
342    pub prepared_nodes: usize,
343    pub directed_edges: usize,
344    pub visibility_checks: usize,
345    pub accepted_undirected_edges: usize,
346    pub retained_bytes: usize,
347}
348
349/// Per-query diagnostics for prepared any-angle search.
350#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
351pub struct PreparedAnyAngleQueryDiagnostics {
352    pub endpoint_visibility_tests: usize,
353    pub endpoint_accepted_edges: usize,
354    pub direct_start_goal_tested: bool,
355    pub direct_start_goal_visible: bool,
356    pub settled_nodes: usize,
357    pub pushes: usize,
358    pub stale_pops: usize,
359    pub path_point_count: usize,
360}
361
362/// Error returned when prepared any-angle preprocessing fails.
363#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
364#[non_exhaustive]
365pub enum PreparedAnyAngleGridBuildError {
366    /// Retained vertex count exceeds the supported repeated-query budget.
367    #[error("prepared any-angle graph exceeds supported node budget ({node_count} > {limit})")]
368    NodeBudgetExceeded { node_count: usize, limit: usize },
369    /// Possible directed-edge count exceeds the supported repeated-query budget.
370    #[error(
371        "prepared any-angle graph exceeds supported directed-edge budget ({edge_count} > {limit})"
372    )]
373    DirectedEdgeBudgetExceeded { edge_count: usize, limit: usize },
374    /// Retained graph bytes exceed the supported repeated-query budget.
375    #[error("prepared any-angle graph exceeds supported retained-byte budget ({bytes} > {limit})")]
376    RetainedBytesBudgetExceeded { bytes: usize, limit: usize },
377    /// CSR offset/neighbor indices would overflow `u32`.
378    #[error("prepared any-angle graph exceeds CSR u32 index capacity ({index_count} > {limit})")]
379    CsrIndexCapacityExceeded { index_count: u64, limit: u32 },
380}
381
382fn preflight_build_limits(node_count: usize) -> Result<(), PreparedAnyAngleGridBuildError> {
383    if node_count > PREPARED_ANY_ANGLE_NODE_BUDGET {
384        return Err(PreparedAnyAngleGridBuildError::NodeBudgetExceeded {
385            node_count,
386            limit: PREPARED_ANY_ANGLE_NODE_BUDGET,
387        });
388    }
389
390    let directed_edge_upper_bound = node_count.checked_mul(node_count.saturating_sub(1)).ok_or(
391        PreparedAnyAngleGridBuildError::DirectedEdgeBudgetExceeded {
392            edge_count: usize::MAX,
393            limit: PREPARED_ANY_ANGLE_DIRECTED_EDGE_BUDGET,
394        },
395    )?;
396    if directed_edge_upper_bound > PREPARED_ANY_ANGLE_DIRECTED_EDGE_BUDGET {
397        return Err(PreparedAnyAngleGridBuildError::DirectedEdgeBudgetExceeded {
398            edge_count: directed_edge_upper_bound,
399            limit: PREPARED_ANY_ANGLE_DIRECTED_EDGE_BUDGET,
400        });
401    }
402
403    let retained_bytes = conservative_retained_bytes_upper_bound(node_count).ok_or(
404        PreparedAnyAngleGridBuildError::RetainedBytesBudgetExceeded {
405            bytes: usize::MAX,
406            limit: PREPARED_ANY_ANGLE_RETAINED_BYTES_BUDGET,
407        },
408    )?;
409    if retained_bytes > PREPARED_ANY_ANGLE_RETAINED_BYTES_BUDGET {
410        return Err(
411            PreparedAnyAngleGridBuildError::RetainedBytesBudgetExceeded {
412                bytes: retained_bytes,
413                limit: PREPARED_ANY_ANGLE_RETAINED_BYTES_BUDGET,
414            },
415        );
416    }
417
418    let offset_capacity = node_count.saturating_add(1) as u64;
419    let index_capacity = directed_edge_upper_bound as u64;
420    if offset_capacity > u64::from(PREPARED_ANY_ANGLE_CSR_U32_INDEX_LIMIT)
421        || index_capacity > u64::from(PREPARED_ANY_ANGLE_CSR_U32_INDEX_LIMIT)
422    {
423        return Err(PreparedAnyAngleGridBuildError::CsrIndexCapacityExceeded {
424            index_count: offset_capacity.max(index_capacity),
425            limit: PREPARED_ANY_ANGLE_CSR_U32_INDEX_LIMIT,
426        });
427    }
428
429    Ok(())
430}
431
432fn conservative_retained_bytes_upper_bound(node_count: usize) -> Option<usize> {
433    let directed_edges = node_count.checked_mul(node_count.saturating_sub(1))?;
434    let node_bytes = node_count.checked_mul(size_of::<Point2>())?;
435    let offset_bytes = node_count.saturating_add(1).checked_mul(size_of::<u32>())?;
436    let neighbor_bytes = directed_edges.checked_mul(size_of::<u32>())?;
437    let weight_bytes = directed_edges.checked_mul(size_of::<f64>())?;
438    node_bytes
439        .checked_add(offset_bytes)?
440        .checked_add(neighbor_bytes)?
441        .checked_add(weight_bytes)
442}
443
444#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
445struct OverlayBuildStats {
446    endpoint_visibility_tests: usize,
447    endpoint_accepted_edges: usize,
448}
449
450type OverlaySearchGraph = (
451    Vec<Point2>,
452    Vec<Vec<(usize, f64)>>,
453    usize,
454    usize,
455    OverlayBuildStats,
456);
457
458fn build_overlay_search_graph(
459    grid: &Grid,
460    prepared_nodes: &[Point2],
461    start: Point2,
462    goal: Point2,
463) -> OverlaySearchGraph {
464    let prepared_count = prepared_nodes.len();
465    let mut supplemental_nodes = Vec::new();
466    let mut stats = OverlayBuildStats::default();
467
468    let start_index = resolve_search_node_index(prepared_nodes, &mut supplemental_nodes, start);
469    let goal_index = resolve_search_node_index(prepared_nodes, &mut supplemental_nodes, goal);
470
471    let node_count = prepared_count + supplemental_nodes.len();
472    let mut overlay_edges = vec![Vec::new(); node_count];
473    overlay_endpoint_edges(
474        grid,
475        prepared_nodes,
476        &supplemental_nodes,
477        &mut overlay_edges,
478        prepared_count,
479        start_index,
480        goal_index,
481        start,
482        &mut stats,
483    );
484    overlay_endpoint_edges(
485        grid,
486        prepared_nodes,
487        &supplemental_nodes,
488        &mut overlay_edges,
489        prepared_count,
490        goal_index,
491        start_index,
492        goal,
493        &mut stats,
494    );
495
496    stats.endpoint_visibility_tests += 1;
497    if segment_is_legal(grid, start, goal) {
498        let weight = start.distance_to(goal);
499        add_undirected_edge(&mut overlay_edges, start_index, goal_index, weight);
500        stats.endpoint_accepted_edges += 1;
501    }
502
503    (
504        supplemental_nodes,
505        overlay_edges,
506        start_index,
507        goal_index,
508        stats,
509    )
510}
511
512fn reconstruct_prepared_path(
513    prepared_nodes: &[Point2],
514    supplemental_nodes: &[Point2],
515    predecessors: &[Option<usize>],
516    goal_index: usize,
517) -> Vec<Point2> {
518    let prepared_count = prepared_nodes.len();
519    let point_for = |index: usize| {
520        if index < prepared_count {
521            prepared_nodes[index]
522        } else {
523            supplemental_nodes[index - prepared_count]
524        }
525    };
526
527    let mut path = vec![point_for(goal_index)];
528    let mut current = goal_index;
529    while let Some(previous) = predecessors[current] {
530        path.push(point_for(previous));
531        if previous == current {
532            break;
533        }
534        current = previous;
535    }
536    path.reverse();
537    path
538}
539
540fn resolve_search_node_index(
541    prepared_nodes: &[Point2],
542    supplemental_nodes: &mut Vec<Point2>,
543    point: Point2,
544) -> usize {
545    match node_index(prepared_nodes, point) {
546        Some(index) => index,
547        None => {
548            let index = prepared_nodes.len() + supplemental_nodes.len();
549            supplemental_nodes.push(point);
550            index
551        }
552    }
553}
554
555#[allow(clippy::too_many_arguments)]
556fn overlay_endpoint_edges(
557    grid: &Grid,
558    prepared_nodes: &[Point2],
559    supplemental_nodes: &[Point2],
560    overlay_edges: &mut [Vec<(usize, f64)>],
561    prepared_count: usize,
562    endpoint_index: usize,
563    other_endpoint_index: usize,
564    endpoint: Point2,
565    stats: &mut OverlayBuildStats,
566) {
567    if endpoint_index < prepared_count {
568        return;
569    }
570
571    for (node_index, node) in prepared_nodes.iter().enumerate().take(prepared_count) {
572        if node_index == endpoint_index || node_index == other_endpoint_index {
573            continue;
574        }
575        let node = *node;
576        stats.endpoint_visibility_tests += 1;
577        if !segment_is_legal(grid, endpoint, node) {
578            continue;
579        }
580        let weight = endpoint.distance_to(node);
581        add_undirected_edge(overlay_edges, endpoint_index, node_index, weight);
582        stats.endpoint_accepted_edges += 1;
583    }
584
585    for (local_index, node) in supplemental_nodes.iter().enumerate() {
586        let node_index = prepared_count + local_index;
587        if node_index == endpoint_index || node_index == other_endpoint_index {
588            continue;
589        }
590        let node = *node;
591        stats.endpoint_visibility_tests += 1;
592        if !segment_is_legal(grid, endpoint, node) {
593            continue;
594        }
595        let weight = endpoint.distance_to(node);
596        add_undirected_edge(overlay_edges, endpoint_index, node_index, weight);
597        stats.endpoint_accepted_edges += 1;
598    }
599}