1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum PolygonScenePackKind {
41 Starter,
43 Stress,
45}
46
47impl PolygonScenePackKind {
48 #[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 #[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum SeparationAxis {
88 Vertical,
90 Horizontal,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum NoPathProof {
99 BoundarySeparator {
102 obstacle_index: usize,
103 axis: SeparationAxis,
104 },
105}
106
107#[derive(Debug, Clone, PartialEq)]
109pub enum PolygonSceneOracle {
110 ExactPathCost {
112 expected_cost: f64,
113 witness_path: Vec<Point2>,
114 },
115 NoPath { proof: NoPathProof },
117}
118
119#[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 #[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 #[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 #[must_use]
152 pub fn benchmark_id(&self, algorithm: &str) -> String {
153 format!("{algorithm}/{}", self.benchmark_path())
154 }
155
156 #[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#[derive(Debug, Clone, PartialEq)]
179pub struct PolygonScenePack {
180 pub pack_id: String,
181 pub format_version: u32,
182 pub scenes: Vec<PolygonSceneFixture>,
183}
184
185pub fn load_polygon_scene_pack() -> Result<PolygonScenePack, ArtifactLoadError> {
191 load_polygon_scene_pack_from_str(MANIFEST).map_err(ArtifactLoadError::polygon)
192}
193
194pub 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}