Skip to main content

condor/
polygonal.rs

1//! Polygonal scene types (re-export) plus root-owned embedded pack loaders.
2//!
3//! # Facade role
4//!
5//! | Surface | Owner |
6//! | --- | --- |
7//! | Geometry substrate (`Point2`, `Polygon`, `PolygonScene`, …) | `condor-pathfinding-geometry` (`condor_geometry`) |
8//! | Exact / continuous solvers (`VisibilityGraph`, `ContinuousShortestPathMap`, …) | geometry crate; re-exported at the facade crate root |
9//! | Embedded pack kinds, TOML manifests, loaders, oracle validation | **this facade module** (root-owned adapter) |
10//! | Structured load errors | [`crate::error`] (root-owned) |
11//!
12//! # Geometry contract
13//!
14//! - [`PolygonScene::is_walkable`]: a point is inside world bounds and not strictly
15//!   inside any obstacle interior. Obstacle boundary points (non-sealed) count
16//!   as free space.
17//! - [`PolygonScene::segment_is_walkable`]: every sample of the open segment
18//!   (endpoints, edge intersections, interval midpoints) must be traversable.
19//! - **Sealed edges**: obstacle edges that lie on the world boundary are
20//!   non-traversable. Endpoints on sealed edges fail
21//!   [`PolygonScene::validate`] / `validate_source` / `validate_goal` even when
22//!   they pass the looser point predicate used by some online checks.
23//!
24//! # Pack loading
25//!
26//! Geometry validates bounds, simple polygons, and endpoint traversability.
27//! Facade loaders then enforce pack oracles (exact path cost or structural
28//! no-path proofs) against that runtime geometry via [`ArtifactLoadError`].
29
30use serde::Deserialize;
31
32use crate::{ArtifactDataError, ArtifactLoadError};
33
34const MANIFEST: &str = include_str!("polygonal/packs/starter.toml");
35const STRESS_MANIFEST: &str = include_str!("polygonal/packs/stress.toml");
36const EPSILON: f64 = 1e-9;
37
38/// Which embedded polygon scene pack a multi-pack loader should open.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum PolygonScenePackKind {
41    /// Starter continuous pack (`polygon-scene-pack-v0-alpha`).
42    Starter,
43    /// Condor-owned continuous stress pack (`polygon-scene-stress-pack-v0-alpha`).
44    Stress,
45}
46
47impl PolygonScenePackKind {
48    /// Stable pack identifier stored in the TOML manifest.
49    #[must_use]
50    pub const fn pack_id(self) -> &'static str {
51        match self {
52            Self::Starter => "polygon-scene-pack-v0-alpha",
53            Self::Stress => "polygon-scene-stress-pack-v0-alpha",
54        }
55    }
56
57    /// Stable logical/report manifest identity for the embedded fixture.
58    #[must_use]
59    pub const fn manifest_path(self) -> &'static str {
60        match self {
61            Self::Starter => "fixtures/polygon_scene_pack_v0.toml",
62            Self::Stress => "fixtures/polygon_scene_stress_pack_v0.toml",
63        }
64    }
65
66    /// Loads and validates the embedded pack for this kind.
67    ///
68    /// # Errors
69    ///
70    /// Returns [`ArtifactLoadError`] when the manifest is malformed or a scene
71    /// violates its geometry or oracle contract.
72    pub fn load(self) -> Result<PolygonScenePack, ArtifactLoadError> {
73        match self {
74            Self::Starter => load_polygon_scene_pack(),
75            Self::Stress => load_polygon_scene_stress_pack(),
76        }
77    }
78}
79
80pub use condor_geometry::{
81    Point2, Polygon, PolygonEndpoint, PolygonScene, PolygonSearchRequest, PolygonValidationError,
82    WorldBounds,
83};
84
85/// Axis of a full-width/height obstacle that separates start from goal.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum SeparationAxis {
88    /// Obstacle spans the world vertically and splits left/right free space.
89    Vertical,
90    /// Obstacle spans the world horizontally and splits below/above free space.
91    Horizontal,
92}
93
94/// Structural certificate that no continuous free-space path exists.
95///
96/// Used by fixture oracles; solvers still prove no-path by search exhaustion.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum NoPathProof {
99    /// Obstacle touches both opposite world boundaries on `axis` and endpoints
100    /// lie on opposite sides of that separator.
101    BoundarySeparator {
102        obstacle_index: usize,
103        axis: SeparationAxis,
104    },
105}
106
107/// Expected search outcome attached to a polygon benchmark fixture.
108#[derive(Debug, Clone, PartialEq)]
109pub enum PolygonSceneOracle {
110    /// Reachable: witness polyline must be walkable and match `expected_cost` within epsilon.
111    ExactPathCost {
112        expected_cost: f64,
113        witness_path: Vec<Point2>,
114    },
115    /// Unreachable: structural separator proof checked at load time.
116    NoPath { proof: NoPathProof },
117}
118
119/// One validated polygon benchmark scene with request and oracle metadata.
120#[derive(Debug, Clone, PartialEq)]
121pub struct PolygonSceneFixture {
122    pub scene_id: String,
123    pub family: String,
124    pub scene: PolygonScene,
125    pub request: PolygonSearchRequest,
126    pub oracle: PolygonSceneOracle,
127}
128
129impl PolygonSceneFixture {
130    /// Criterion / report slice: `"reachable"` or `"no-path"`.
131    #[must_use]
132    pub fn benchmark_slice(&self) -> &'static str {
133        match self.oracle {
134            PolygonSceneOracle::ExactPathCost { .. } => "reachable",
135            PolygonSceneOracle::NoPath { .. } => "no-path",
136        }
137    }
138
139    /// Stable path fragment `{slice}/{family}/{scene_id}` for report grouping.
140    #[must_use]
141    pub fn benchmark_path(&self) -> String {
142        format!(
143            "{}/{}/{}",
144            self.benchmark_slice(),
145            self.family,
146            self.scene_id
147        )
148    }
149
150    /// Full benchmark id `{algorithm}/{benchmark_path}` for capture reports.
151    #[must_use]
152    pub fn benchmark_id(&self, algorithm: &str) -> String {
153        format!("{algorithm}/{}", self.benchmark_path())
154    }
155
156    /// Human-readable reason string for scorecards and catalog exports.
157    #[must_use]
158    pub fn benchmark_reason(&self) -> String {
159        let obstacle_count = self.scene.obstacles.len();
160        let obstacle_label = if obstacle_count == 1 {
161            "polygon obstacle"
162        } else {
163            "polygon obstacles"
164        };
165
166        match self.oracle {
167            PolygonSceneOracle::ExactPathCost { .. } => {
168                format!("Exact continuous scene with {obstacle_count} {obstacle_label}")
169            }
170            PolygonSceneOracle::NoPath { .. } => {
171                format!("Continuous separator scene with {obstacle_count} {obstacle_label}")
172            }
173        }
174    }
175}
176
177/// Loaded polygon benchmark pack: identity, format version, and validated fixtures.
178#[derive(Debug, Clone, PartialEq)]
179pub struct PolygonScenePack {
180    pub pack_id: String,
181    pub format_version: u32,
182    pub scenes: Vec<PolygonSceneFixture>,
183}
184
185/// Loads the embedded starter polygon scene pack (`polygon-scene-pack-v0-alpha`).
186///
187/// # Errors
188///
189/// Returns [`ArtifactLoadError`] when the manifest or any fixture fails validation.
190pub fn load_polygon_scene_pack() -> Result<PolygonScenePack, ArtifactLoadError> {
191    load_polygon_scene_pack_from_str(MANIFEST).map_err(ArtifactLoadError::polygon)
192}
193
194/// Loads the embedded continuous stress pack (`polygon-scene-stress-pack-v0-alpha`).
195///
196/// # Errors
197///
198/// Returns [`ArtifactLoadError`] when the manifest or any fixture fails validation.
199pub fn load_polygon_scene_stress_pack() -> Result<PolygonScenePack, ArtifactLoadError> {
200    load_polygon_scene_pack_from_str(STRESS_MANIFEST).map_err(ArtifactLoadError::polygon)
201}
202
203fn load_polygon_scene_pack_from_str(manifest: &str) -> Result<PolygonScenePack, ArtifactDataError> {
204    let pack: PolygonScenePackManifest = toml::from_str(manifest)?;
205    if pack.format_version != 1 {
206        return Err(ArtifactDataError::unsupported_version(
207            "polygon scene pack",
208            pack.format_version,
209        ));
210    }
211
212    if pack.pack_id.trim().is_empty() {
213        return Err(ArtifactDataError::empty_identifier(
214            "polygon scene pack",
215            "pack_id",
216        ));
217    }
218
219    let scenes = pack
220        .scenes
221        .into_iter()
222        .map(build_fixture)
223        .collect::<Result<Vec<_>, _>>()?;
224
225    Ok(PolygonScenePack {
226        pack_id: pack.pack_id,
227        format_version: pack.format_version,
228        scenes,
229    })
230}
231
232#[derive(Debug, Deserialize)]
233struct PolygonScenePackManifest {
234    pack_id: String,
235    format_version: u32,
236    scenes: Vec<PolygonSceneSpec>,
237}
238
239#[derive(Debug, Deserialize)]
240struct PolygonSceneSpec {
241    scene_id: String,
242    family: String,
243    world_bounds: [f64; 4],
244    start: [f64; 2],
245    goal: [f64; 2],
246    #[serde(default)]
247    obstacles: Vec<PolygonSpec>,
248    oracle_kind: String,
249    expected_cost: Option<f64>,
250    witness_path: Option<Vec<[f64; 2]>>,
251    proof_kind: Option<String>,
252    proof_obstacle_index: Option<usize>,
253    proof_axis: Option<String>,
254}
255
256#[derive(Debug, Deserialize)]
257struct PolygonSpec {
258    vertices: Vec<[f64; 2]>,
259}
260
261fn build_fixture(spec: PolygonSceneSpec) -> Result<PolygonSceneFixture, ArtifactDataError> {
262    if spec.family.trim().is_empty() {
263        return Err(ArtifactDataError::missing_required_field(
264            spec.scene_id.clone(),
265            "family",
266        ));
267    }
268
269    let scene = PolygonScene {
270        world_bounds: WorldBounds::new(
271            Point2::new(spec.world_bounds[0], spec.world_bounds[1]),
272            Point2::new(spec.world_bounds[2], spec.world_bounds[3]),
273        ),
274        obstacles: spec
275            .obstacles
276            .into_iter()
277            .map(|polygon| {
278                Polygon::new(
279                    polygon
280                        .vertices
281                        .into_iter()
282                        .map(|vertex| Point2::new(vertex[0], vertex[1]))
283                        .collect(),
284                )
285            })
286            .collect(),
287    };
288    let request = PolygonSearchRequest::new(
289        Point2::new(spec.start[0], spec.start[1]),
290        Point2::new(spec.goal[0], spec.goal[1]),
291    );
292
293    scene.validate(request)?;
294
295    let oracle = match spec.oracle_kind.as_str() {
296        "exact-path-cost" => {
297            let expected_cost = spec.expected_cost.ok_or_else(|| {
298                ArtifactDataError::missing_required_field(spec.scene_id.clone(), "expected_cost")
299            })?;
300            let witness_path = spec
301                .witness_path
302                .ok_or_else(|| {
303                    ArtifactDataError::missing_required_field(spec.scene_id.clone(), "witness_path")
304                })?
305                .into_iter()
306                .map(|point| Point2::new(point[0], point[1]))
307                .collect();
308
309            PolygonSceneOracle::ExactPathCost {
310                expected_cost,
311                witness_path,
312            }
313        }
314        "no-path" => {
315            let proof_kind = spec.proof_kind.as_deref().ok_or_else(|| {
316                ArtifactDataError::missing_required_field(spec.scene_id.clone(), "proof_kind")
317            })?;
318            let obstacle_index = spec.proof_obstacle_index.ok_or_else(|| {
319                ArtifactDataError::missing_required_field(
320                    spec.scene_id.clone(),
321                    "proof_obstacle_index",
322                )
323            })?;
324            let axis = match spec.proof_axis.as_deref() {
325                Some("vertical") => SeparationAxis::Vertical,
326                Some("horizontal") => SeparationAxis::Horizontal,
327                Some(other) => {
328                    return Err(ArtifactDataError::invalid_value(
329                        spec.scene_id.clone(),
330                        "proof_axis",
331                        crate::ArtifactContractLocation::NONE,
332                        other,
333                    ));
334                }
335                None => {
336                    return Err(ArtifactDataError::missing_required_field(
337                        spec.scene_id.clone(),
338                        "proof_axis",
339                    ));
340                }
341            };
342
343            let proof = match proof_kind {
344                "boundary-separator" => NoPathProof::BoundarySeparator {
345                    obstacle_index,
346                    axis,
347                },
348                other => {
349                    return Err(ArtifactDataError::invalid_value(
350                        spec.scene_id.clone(),
351                        "proof_kind",
352                        crate::ArtifactContractLocation::NONE,
353                        other,
354                    ));
355                }
356            };
357
358            PolygonSceneOracle::NoPath { proof }
359        }
360        other => {
361            return Err(ArtifactDataError::invalid_value(
362                spec.scene_id.clone(),
363                "oracle_kind",
364                crate::ArtifactContractLocation::NONE,
365                other,
366            ));
367        }
368    };
369
370    validate_oracle(&scene, request, &oracle, spec.scene_id.as_str())?;
371
372    Ok(PolygonSceneFixture {
373        scene_id: spec.scene_id,
374        family: spec.family,
375        scene,
376        request,
377        oracle,
378    })
379}
380
381fn validate_oracle(
382    scene: &PolygonScene,
383    request: PolygonSearchRequest,
384    oracle: &PolygonSceneOracle,
385    scene_id: &str,
386) -> Result<(), ArtifactDataError> {
387    match oracle {
388        PolygonSceneOracle::ExactPathCost {
389            expected_cost,
390            witness_path,
391        } => validate_exact_path_oracle(scene, request, *expected_cost, witness_path, scene_id),
392        PolygonSceneOracle::NoPath { proof } => {
393            validate_no_path_proof(scene, request, *proof, scene_id)
394        }
395    }
396}
397
398fn validate_exact_path_oracle(
399    scene: &PolygonScene,
400    request: PolygonSearchRequest,
401    expected_cost: f64,
402    witness_path: &[Point2],
403    scene_id: &str,
404) -> Result<(), ArtifactDataError> {
405    if witness_path.len() < 2 {
406        return Err(ArtifactDataError::invalid_value(
407            scene_id,
408            "witness_path",
409            crate::ArtifactContractLocation::NONE,
410            witness_path.len(),
411        ));
412    }
413
414    if witness_path.first() != Some(&request.start) {
415        return Err(ArtifactDataError::inconsistent_data(
416            scene_id,
417            "witness_path/start",
418            crate::ArtifactContractLocation::index(0),
419            format!("{:?}", witness_path.first()),
420        ));
421    }
422
423    if witness_path.last() != Some(&request.goal) {
424        return Err(ArtifactDataError::inconsistent_data(
425            scene_id,
426            "witness_path/goal",
427            crate::ArtifactContractLocation::index(witness_path.len() - 1),
428            format!("{:?}", witness_path.last()),
429        ));
430    }
431
432    for pair in witness_path.windows(2) {
433        validate_polyline_segment(scene, pair[0], pair[1], scene_id)?;
434    }
435
436    let actual_cost = polyline_length(witness_path);
437    if (actual_cost - expected_cost).abs() > EPSILON {
438        return Err(ArtifactDataError::inconsistent_data(
439            scene_id,
440            "witness_path/expected_cost",
441            crate::ArtifactContractLocation::NONE,
442            actual_cost,
443        ));
444    }
445
446    Ok(())
447}
448
449fn validate_no_path_proof(
450    scene: &PolygonScene,
451    request: PolygonSearchRequest,
452    proof: NoPathProof,
453    scene_id: &str,
454) -> Result<(), ArtifactDataError> {
455    match proof {
456        NoPathProof::BoundarySeparator {
457            obstacle_index,
458            axis,
459        } => {
460            let obstacle = scene.obstacles.get(obstacle_index).ok_or_else(|| {
461                ArtifactDataError::invalid_reference(
462                    scene_id,
463                    "proof_obstacle_index",
464                    crate::ArtifactContractLocation::index(obstacle_index),
465                    obstacle_index,
466                )
467            })?;
468            let (min_x, max_x, min_y, max_y) = polygon_bounds(obstacle.vertices());
469
470            match axis {
471                SeparationAxis::Vertical => {
472                    if (min_y - scene.world_bounds.min.y).abs() > EPSILON
473                        || (max_y - scene.world_bounds.max.y).abs() > EPSILON
474                    {
475                        return Err(ArtifactDataError::inconsistent_data(
476                            scene_id,
477                            "proof_axis/world_bounds",
478                            crate::ArtifactContractLocation::index(obstacle_index),
479                            "vertical",
480                        ));
481                    }
482
483                    let start_left = request.start.x < min_x;
484                    let goal_left = request.goal.x < min_x;
485                    let start_right = request.start.x > max_x;
486                    let goal_right = request.goal.x > max_x;
487                    if !((start_left && goal_right) || (start_right && goal_left)) {
488                        return Err(ArtifactDataError::inconsistent_data(
489                            scene_id,
490                            "proof_axis/start_goal",
491                            crate::ArtifactContractLocation::index(obstacle_index),
492                            "vertical",
493                        ));
494                    }
495                }
496                SeparationAxis::Horizontal => {
497                    if (min_x - scene.world_bounds.min.x).abs() > EPSILON
498                        || (max_x - scene.world_bounds.max.x).abs() > EPSILON
499                    {
500                        return Err(ArtifactDataError::inconsistent_data(
501                            scene_id,
502                            "proof_axis/world_bounds",
503                            crate::ArtifactContractLocation::index(obstacle_index),
504                            "horizontal",
505                        ));
506                    }
507
508                    let start_below = request.start.y < min_y;
509                    let goal_below = request.goal.y < min_y;
510                    let start_above = request.start.y > max_y;
511                    let goal_above = request.goal.y > max_y;
512                    if !((start_below && goal_above) || (start_above && goal_below)) {
513                        return Err(ArtifactDataError::inconsistent_data(
514                            scene_id,
515                            "proof_axis/start_goal",
516                            crate::ArtifactContractLocation::index(obstacle_index),
517                            "horizontal",
518                        ));
519                    }
520                }
521            }
522
523            Ok(())
524        }
525    }
526}
527
528fn validate_polyline_segment(
529    scene: &PolygonScene,
530    start: Point2,
531    end: Point2,
532    scene_id: &str,
533) -> Result<(), ArtifactDataError> {
534    if !scene.segment_is_walkable(start, end) {
535        return Err(ArtifactDataError::inconsistent_data(
536            scene_id,
537            "witness_path",
538            crate::ArtifactContractLocation::NONE,
539            format!("{:?}->{:?}", start, end),
540        ));
541    }
542
543    Ok(())
544}
545
546fn polyline_length(path: &[Point2]) -> f64 {
547    path.windows(2)
548        .map(|pair| pair[0].distance_to(pair[1]))
549        .sum()
550}
551
552fn polygon_bounds(vertices: &[Point2]) -> (f64, f64, f64, f64) {
553    let min_x = vertices
554        .iter()
555        .map(|vertex| vertex.x)
556        .fold(f64::INFINITY, f64::min);
557    let max_x = vertices
558        .iter()
559        .map(|vertex| vertex.x)
560        .fold(f64::NEG_INFINITY, f64::max);
561    let min_y = vertices
562        .iter()
563        .map(|vertex| vertex.y)
564        .fold(f64::INFINITY, f64::min);
565    let max_y = vertices
566        .iter()
567        .map(|vertex| vertex.y)
568        .fold(f64::NEG_INFINITY, f64::max);
569    (min_x, max_x, min_y, max_y)
570}
571
572#[cfg(test)]
573mod tests {
574    use super::{PolygonValidationError, load_polygon_scene_pack_from_str};
575    use crate::{ArtifactContractError, ArtifactContractLocation, ArtifactDataError};
576
577    #[test]
578    fn rejects_self_intersecting_obstacles() {
579        let manifest = r#"
580pack_id = "polygon-scene-pack-v0-alpha"
581format_version = 1
582
583[[scenes]]
584scene_id = "self-intersecting"
585family = "invalid"
586world_bounds = [0.0, 0.0, 10.0, 10.0]
587start = [1.0, 1.0]
588goal = [9.0, 1.0]
589oracle_kind = "exact-path-cost"
590expected_cost = 8.0
591witness_path = [[1.0, 1.0], [9.0, 1.0]]
592
593[[scenes.obstacles]]
594vertices = [[2.0, 7.0], [4.0, 2.0], [8.0, 7.0], [2.0, 4.0], [8.0, 4.0]]
595"#;
596
597        let error = load_polygon_scene_pack_from_str(manifest).unwrap_err();
598        assert!(matches!(
599            error,
600            ArtifactDataError::PolygonValidation(PolygonValidationError::SelfIntersection {
601                obstacle_index: 0,
602                first_edge_start_index: 0,
603                second_edge_start_index: 2,
604            })
605        ));
606    }
607
608    #[test]
609    fn rejects_overlapping_obstacles() {
610        let manifest = r#"
611pack_id = "polygon-scene-pack-v0-alpha"
612format_version = 1
613
614[[scenes]]
615scene_id = "overlapping-obstacles"
616family = "invalid"
617world_bounds = [0.0, 0.0, 10.0, 10.0]
618start = [1.0, 1.0]
619goal = [9.0, 1.0]
620oracle_kind = "exact-path-cost"
621expected_cost = 8.0
622witness_path = [[1.0, 1.0], [9.0, 1.0]]
623
624[[scenes.obstacles]]
625vertices = [[2.0, 2.0], [5.0, 2.0], [5.0, 5.0], [2.0, 5.0]]
626
627[[scenes.obstacles]]
628vertices = [[4.0, 3.0], [7.0, 3.0], [7.0, 6.0], [4.0, 6.0]]
629"#;
630
631        let error = load_polygon_scene_pack_from_str(manifest).unwrap_err();
632        assert!(
633            error.to_string().contains("must be disjoint"),
634            "unexpected error: {error}"
635        );
636    }
637
638    #[test]
639    fn rejects_invalid_exact_path_cost_oracles() {
640        let manifest = r#"
641pack_id = "polygon-scene-pack-v0-alpha"
642format_version = 1
643
644[[scenes]]
645scene_id = "wrong-cost"
646family = "invalid"
647world_bounds = [0.0, 0.0, 10.0, 10.0]
648start = [1.0, 1.0]
649goal = [9.0, 1.0]
650oracle_kind = "exact-path-cost"
651expected_cost = 7.0
652witness_path = [[1.0, 1.0], [9.0, 1.0]]
653"#;
654
655        let error = load_polygon_scene_pack_from_str(manifest).unwrap_err();
656        assert!(matches!(
657            error,
658            ArtifactDataError::Contract(contract)
659                if *contract == ArtifactContractError::InconsistentData {
660                artifact: "wrong-cost".into(),
661                field: "witness_path/expected_cost".into(),
662                location: ArtifactContractLocation::NONE,
663                value: "8".into(),
664            }
665        ));
666    }
667
668    #[test]
669    fn rejects_invalid_boundary_separator_proofs() {
670        let manifest = r#"
671pack_id = "polygon-scene-pack-v0-alpha"
672format_version = 1
673
674[[scenes]]
675scene_id = "bad-proof"
676family = "invalid"
677world_bounds = [0.0, 0.0, 10.0, 10.0]
678start = [2.0, 5.0]
679goal = [8.0, 5.0]
680oracle_kind = "no-path"
681proof_kind = "boundary-separator"
682proof_obstacle_index = 1
683proof_axis = "vertical"
684
685[[scenes.obstacles]]
686vertices = [[4.0, 0.0], [6.0, 0.0], [6.0, 10.0], [4.0, 10.0]]
687"#;
688
689        let error = load_polygon_scene_pack_from_str(manifest).unwrap_err();
690        assert!(matches!(
691            error,
692            ArtifactDataError::Contract(contract)
693                if *contract == ArtifactContractError::InvalidReference {
694                artifact: "bad-proof".into(),
695                field: "proof_obstacle_index".into(),
696                location: ArtifactContractLocation::index(1),
697                value: "1".into(),
698            }
699        ));
700    }
701}